From 1a25a95b044c051f6ead9ba416eb3404e6e7f803 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 22 Dec 2025 13:11:19 -0800 Subject: [PATCH 001/200] fix!: Make `ISelectable.workspace` an instance of `WorkspaceSvg` (#9534) --- core/interfaces/i_selectable.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/interfaces/i_selectable.ts b/core/interfaces/i_selectable.ts index 5374f50cd..808f358ac 100644 --- a/core/interfaces/i_selectable.ts +++ b/core/interfaces/i_selectable.ts @@ -6,7 +6,7 @@ // Former goog.module ID: Blockly.ISelectable -import type {Workspace} from '../workspace.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; import {IFocusableNode, isFocusableNode} from './i_focusable_node.js'; /** @@ -20,7 +20,7 @@ import {IFocusableNode, isFocusableNode} from './i_focusable_node.js'; export interface ISelectable extends IFocusableNode { id: string; - workspace: Workspace; + workspace: WorkspaceSvg; /** Select this. Highlight it visually. */ select(): void; From ba329e40e71ad2b37220b8441d18a6e6a9b87e72 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 22 Dec 2025 13:21:53 -0800 Subject: [PATCH 002/200] fix!: Remove event dependencies on XML (#9536) --- core/events/events_block_create.ts | 15 ------------- core/events/events_block_delete.ts | 18 +--------------- core/events/events_comment_create.ts | 15 ------------- core/events/events_comment_delete.ts | 15 ------------- tests/mocha/event_block_create_test.js | 5 ----- tests/mocha/event_test.js | 29 +------------------------- 6 files changed, 2 insertions(+), 95 deletions(-) diff --git a/core/events/events_block_create.ts b/core/events/events_block_create.ts index ca6979454..dc77cd53d 100644 --- a/core/events/events_block_create.ts +++ b/core/events/events_block_create.ts @@ -14,9 +14,7 @@ import type {Block} from '../block.js'; import * as registry from '../registry.js'; import * as blocks from '../serialization/blocks.js'; -import * as utilsXml from '../utils/xml.js'; import {Workspace} from '../workspace.js'; -import * as Xml from '../xml.js'; import {BlockBase, BlockBaseJson} from './events_block_base.js'; import {EventType} from './type.js'; import * as eventUtils from './utils.js'; @@ -28,9 +26,6 @@ import * as eventUtils from './utils.js'; export class BlockCreate extends BlockBase { override type = EventType.BLOCK_CREATE; - /** The XML representation of the created block(s). */ - xml?: Element | DocumentFragment; - /** The JSON respresentation of the created block(s). */ json?: blocks.State; @@ -50,7 +45,6 @@ export class BlockCreate extends BlockBase { this.recordUndo = false; } - this.xml = Xml.blockToDomWithXY(opt_block); this.ids = eventUtils.getDescendantIds(opt_block); this.json = blocks.save(opt_block, {addCoordinates: true}) as blocks.State; @@ -63,12 +57,6 @@ export class BlockCreate extends BlockBase { */ override toJson(): BlockCreateJson { const json = super.toJson() as BlockCreateJson; - if (!this.xml) { - throw new Error( - 'The block XML is undefined. Either pass a block to ' + - 'the constructor, or call fromJson', - ); - } if (!this.ids) { throw new Error( 'The block IDs are undefined. Either pass a block to ' + @@ -81,7 +69,6 @@ export class BlockCreate extends BlockBase { 'the constructor, or call fromJson', ); } - json['xml'] = Xml.domToText(this.xml); json['ids'] = this.ids; json['json'] = this.json; if (!this.recordUndo) { @@ -109,7 +96,6 @@ export class BlockCreate extends BlockBase { workspace, event ?? new BlockCreate(), ) as BlockCreate; - newEvent.xml = utilsXml.textToDom(json['xml']); newEvent.ids = json['ids']; newEvent.json = json['json'] as blocks.State; if (json['recordUndo'] !== undefined) { @@ -176,7 +162,6 @@ const allShadowBlocks = function ( }; export interface BlockCreateJson extends BlockBaseJson { - xml: string; ids: string[]; json: object; recordUndo?: boolean; diff --git a/core/events/events_block_delete.ts b/core/events/events_block_delete.ts index 5dd231606..97dc21490 100644 --- a/core/events/events_block_delete.ts +++ b/core/events/events_block_delete.ts @@ -14,9 +14,7 @@ import type {Block} from '../block.js'; import * as registry from '../registry.js'; import * as blocks from '../serialization/blocks.js'; -import * as utilsXml from '../utils/xml.js'; import {Workspace} from '../workspace.js'; -import * as Xml from '../xml.js'; import {BlockBase, BlockBaseJson} from './events_block_base.js'; import {EventType} from './type.js'; import * as eventUtils from './utils.js'; @@ -26,9 +24,6 @@ import * as eventUtils from './utils.js'; * deleted. */ export class BlockDelete extends BlockBase { - /** The XML representation of the deleted block(s). */ - oldXml?: Element | DocumentFragment; - /** The JSON respresentation of the deleted block(s). */ oldJson?: blocks.State; @@ -56,7 +51,6 @@ export class BlockDelete extends BlockBase { this.recordUndo = false; } - this.oldXml = Xml.blockToDomWithXY(opt_block); this.ids = eventUtils.getDescendantIds(opt_block); this.wasShadow = opt_block.isShadow(); this.oldJson = blocks.save(opt_block, { @@ -71,12 +65,6 @@ export class BlockDelete extends BlockBase { */ override toJson(): BlockDeleteJson { const json = super.toJson() as BlockDeleteJson; - if (!this.oldXml) { - throw new Error( - 'The old block XML is undefined. Either pass a block ' + - 'to the constructor, or call fromJson', - ); - } if (!this.ids) { throw new Error( 'The block IDs are undefined. Either pass a block to ' + @@ -95,7 +83,6 @@ export class BlockDelete extends BlockBase { 'to the constructor, or call fromJson', ); } - json['oldXml'] = Xml.domToText(this.oldXml); json['ids'] = this.ids; json['wasShadow'] = this.wasShadow; json['oldJson'] = this.oldJson; @@ -124,10 +111,8 @@ export class BlockDelete extends BlockBase { workspace, event ?? new BlockDelete(), ) as BlockDelete; - newEvent.oldXml = utilsXml.textToDom(json['oldXml']); newEvent.ids = json['ids']; - newEvent.wasShadow = - json['wasShadow'] || newEvent.oldXml.tagName.toLowerCase() === 'shadow'; + newEvent.wasShadow = json['wasShadow']; newEvent.oldJson = json['oldJson']; if (json['recordUndo'] !== undefined) { newEvent.recordUndo = json['recordUndo']; @@ -172,7 +157,6 @@ export class BlockDelete extends BlockBase { } export interface BlockDeleteJson extends BlockBaseJson { - oldXml: string; ids: string[]; wasShadow: boolean; oldJson: blocks.State; diff --git a/core/events/events_comment_create.ts b/core/events/events_comment_create.ts index 637107e3f..7e940b20d 100644 --- a/core/events/events_comment_create.ts +++ b/core/events/events_comment_create.ts @@ -14,9 +14,7 @@ import type {WorkspaceComment} from '../comments/workspace_comment.js'; import * as registry from '../registry.js'; import * as comments from '../serialization/workspace_comments.js'; -import * as utilsXml from '../utils/xml.js'; import type {Workspace} from '../workspace.js'; -import * as Xml from '../xml.js'; import {CommentBase, CommentBaseJson} from './events_comment_base.js'; import {EventType} from './type.js'; @@ -26,9 +24,6 @@ import {EventType} from './type.js'; export class CommentCreate extends CommentBase { override type = EventType.COMMENT_CREATE; - /** The XML representation of the created workspace comment. */ - xml?: Element | DocumentFragment; - /** The JSON representation of the created workspace comment. */ json?: comments.State; @@ -43,7 +38,6 @@ export class CommentCreate extends CommentBase { return; // Blank event to be populated by fromJson. } - this.xml = Xml.saveWorkspaceComment(opt_comment); this.json = comments.save(opt_comment, {addCoordinates: true}); } @@ -55,19 +49,12 @@ export class CommentCreate extends CommentBase { */ override toJson(): CommentCreateJson { const json = super.toJson() as CommentCreateJson; - if (!this.xml) { - throw new Error( - 'The comment XML is undefined. Either pass a comment to ' + - 'the constructor, or call fromJson', - ); - } if (!this.json) { throw new Error( 'The comment JSON is undefined. Either pass a block to ' + 'the constructor, or call fromJson', ); } - json['xml'] = Xml.domToText(this.xml); json['json'] = this.json; return json; } @@ -91,7 +78,6 @@ export class CommentCreate extends CommentBase { workspace, event ?? new CommentCreate(), ) as CommentCreate; - newEvent.xml = utilsXml.textToDom(json['xml']); newEvent.json = json['json']; return newEvent; } @@ -107,7 +93,6 @@ export class CommentCreate extends CommentBase { } export interface CommentCreateJson extends CommentBaseJson { - xml: string; json: object; } diff --git a/core/events/events_comment_delete.ts b/core/events/events_comment_delete.ts index 579131e50..75f7e10ba 100644 --- a/core/events/events_comment_delete.ts +++ b/core/events/events_comment_delete.ts @@ -14,9 +14,7 @@ import type {WorkspaceComment} from '../comments/workspace_comment.js'; import * as registry from '../registry.js'; import * as comments from '../serialization/workspace_comments.js'; -import * as utilsXml from '../utils/xml.js'; import type {Workspace} from '../workspace.js'; -import * as Xml from '../xml.js'; import {CommentBase, CommentBaseJson} from './events_comment_base.js'; import {EventType} from './type.js'; @@ -26,9 +24,6 @@ import {EventType} from './type.js'; export class CommentDelete extends CommentBase { override type = EventType.COMMENT_DELETE; - /** The XML representation of the deleted workspace comment. */ - xml?: Element; - /** The JSON representation of the created workspace comment. */ json?: comments.State; @@ -43,7 +38,6 @@ export class CommentDelete extends CommentBase { return; // Blank event to be populated by fromJson. } - this.xml = Xml.saveWorkspaceComment(opt_comment); this.json = comments.save(opt_comment, {addCoordinates: true}); } @@ -63,19 +57,12 @@ export class CommentDelete extends CommentBase { */ override toJson(): CommentDeleteJson { const json = super.toJson() as CommentDeleteJson; - if (!this.xml) { - throw new Error( - 'The comment XML is undefined. Either pass a comment to ' + - 'the constructor, or call fromJson', - ); - } if (!this.json) { throw new Error( 'The comment JSON is undefined. Either pass a block to ' + 'the constructor, or call fromJson', ); } - json['xml'] = Xml.domToText(this.xml); json['json'] = this.json; return json; } @@ -99,14 +86,12 @@ export class CommentDelete extends CommentBase { workspace, event ?? new CommentDelete(), ) as CommentDelete; - newEvent.xml = utilsXml.textToDom(json['xml']); newEvent.json = json['json']; return newEvent; } } export interface CommentDeleteJson extends CommentBaseJson { - xml: string; json: object; } diff --git a/tests/mocha/event_block_create_test.js b/tests/mocha/event_block_create_test.js index 1672b56bb..b0f5001de 100644 --- a/tests/mocha/event_block_create_test.js +++ b/tests/mocha/event_block_create_test.js @@ -52,9 +52,6 @@ suite('Block Create Event', function () { this.workspace.id, 'shadowId', ); - const calls = this.eventsFireStub.getCalls(); - const event = calls[calls.length - 1].args[0]; - assert.equal(event.xml.tagName, 'shadow'); }); test('Does not create extra shadow blocks', function () { @@ -100,8 +97,6 @@ suite('Block Create Event', function () { const json = origEvent.toJson(); const newEvent = new Blockly.Events.fromJson(json, this.workspace); - delete origEvent.xml; // xml fails deep equals for some reason. - delete newEvent.xml; // xml fails deep equals for some reason. assert.deepEqual(newEvent, origEvent); }); diff --git a/tests/mocha/event_test.js b/tests/mocha/event_test.js index 475c76a5f..fd7d904c1 100644 --- a/tests/mocha/event_test.js +++ b/tests/mocha/event_test.js @@ -639,10 +639,6 @@ suite('Events', function () { type: 'create', group: '', blockId: thisObj.block.id, - xml: - '' + - '', ids: [thisObj.block.id], json: { 'type': 'simple_test_block', @@ -660,10 +656,6 @@ suite('Events', function () { type: 'create', group: '', blockId: thisObj.shadowBlock.id, - xml: - '' + - '', ids: [thisObj.shadowBlock.id], json: { 'type': 'simple_test_block', @@ -682,10 +674,6 @@ suite('Events', function () { type: 'delete', group: '', blockId: thisObj.block.id, - oldXml: - '' + - '', ids: [thisObj.block.id], wasShadow: false, oldJson: { @@ -704,10 +692,6 @@ suite('Events', function () { type: 'delete', group: '', blockId: thisObj.shadowBlock.id, - oldXml: - '' + - '', ids: [thisObj.shadowBlock.id], wasShadow: true, oldJson: { @@ -765,11 +749,6 @@ suite('Events', function () { type: 'comment_create', group: '', commentId: thisObj.comment.id, - // TODO: Before merging, is this a dumb change detector? - xml: Blockly.Xml.domToText( - Blockly.Xml.saveWorkspaceComment(thisObj.comment), - {addCoordinates: true}, - ), json: { height: 100, width: 120, @@ -788,11 +767,6 @@ suite('Events', function () { type: 'comment_delete', group: '', commentId: thisObj.comment.id, - // TODO: Before merging, is this a dumb change detector? - xml: Blockly.Xml.domToText( - Blockly.Xml.saveWorkspaceComment(thisObj.comment), - {addCoordinates: true}, - ), json: { height: 100, width: 120, @@ -1405,7 +1379,6 @@ suite('Events', function () { const block = workspaceSvg.newBlock(''); block.initSvg(); block.setCommentText('test comment'); - const expectedOldXml = Blockly.Xml.blockToDomWithXY(block); const expectedId = block.id; // Run all queued events. @@ -1426,7 +1399,7 @@ suite('Events', function () { this.eventsFireSpy, 0, Blockly.Events.BlockDelete, - {oldXml: expectedOldXml, group: ''}, + {group: ''}, workspaceSvg.id, expectedId, ); From 43af9ab7044ce69a880ccff9a2917320e48d5d23 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 20 Jan 2026 08:13:42 -0800 Subject: [PATCH 003/200] feat!: Use box-sizing: border-box by default (#9556) --- core/css.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/css.ts b/core/css.ts index 503b6362b..37de8e761 100644 --- a/core/css.ts +++ b/core/css.ts @@ -59,6 +59,15 @@ export function inject(hasCss: boolean, pathToMedia: string) { * The CSS content for Blockly. */ let content = ` +:is( + .injectionDiv, + .blocklyWidgetDiv, + .blocklyDropdownDiv, + .blocklyTooltipDiv, +) * { + box-sizing: border-box; +} + .blocklySvg { background-color: #fff; outline: none; From 01dc838ebb33f8b2aeb09d8f92c48ed42af96078 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 20 Jan 2026 08:14:02 -0800 Subject: [PATCH 004/200] chore(deps): Bump rimraf to v6 (#9557) --- package-lock.json | 96 ++++++++++++----------------------------------- package.json | 2 +- 2 files changed, 25 insertions(+), 73 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1822c7604..feafa4e4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "prettier-plugin-organize-imports": "^4.0.0", "puppeteer-core": "^24.17.0", "readline-sync": "^1.4.10", - "rimraf": "^5.0.0", + "rimraf": "^6.1.2", "typescript": "^5.3.3", "typescript-eslint": "^8.16.0", "webdriverio": "^9.0.7", @@ -9030,102 +9030,54 @@ "dev": true }, "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^10.3.7" + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/rimraf/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/rimraf/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" diff --git a/package.json b/package.json index 8a68320d2..f4e5133f6 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "prettier-plugin-organize-imports": "^4.0.0", "puppeteer-core": "^24.17.0", "readline-sync": "^1.4.10", - "rimraf": "^5.0.0", + "rimraf": "^6.1.2", "typescript": "^5.3.3", "typescript-eslint": "^8.16.0", "webdriverio": "^9.0.7", From 2678f5845fd0b43b1258daef82c8d1252c3a8bb5 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 20 Jan 2026 08:15:01 -0800 Subject: [PATCH 005/200] fix: Use SVG icon assets (#9569) --- core/css.ts | 2 +- core/sprites.ts | 2 +- core/toolbox/category.ts | 2 +- media/sprites.png | Bin 2595 -> 0 bytes 4 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 media/sprites.png diff --git a/core/css.ts b/core/css.ts index 37de8e761..9a3323129 100644 --- a/core/css.ts +++ b/core/css.ts @@ -461,7 +461,7 @@ input[type=number] { } .blocklyMenuItemSelected .blocklyMenuItemCheckbox { - background: url(<<>>/sprites.png) no-repeat -48px -16px; + background: url(<<>>/sprites.svg) no-repeat -48px -16px; float: left; margin-left: -24px; position: static; /* Scroll with the menu. */ diff --git a/core/sprites.ts b/core/sprites.ts index 41933de3d..300e1ce06 100644 --- a/core/sprites.ts +++ b/core/sprites.ts @@ -11,5 +11,5 @@ export const SPRITE = { width: 96, height: 124, - url: 'sprites.png', + url: 'sprites.svg', }; diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index 7b0db7b3f..dd42a549f 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -692,7 +692,7 @@ Css.register(` } .blocklyToolboxCategoryIcon { - background-image: url(<<>>/sprites.png); + background-image: url(<<>>/sprites.svg); height: 16px; vertical-align: middle; visibility: hidden; diff --git a/media/sprites.png b/media/sprites.png deleted file mode 100644 index 20aadb6c4c31f4a46f0a3316241788687c2fc444..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2595 zcmZ{mc|6mPAICr2%r$G9Gq=d~t=uisVbfeWBNdTjj>;KwOlgg3o6w=$VTiUQqA*5Z zqeBNdlP~3NLYQlgzWe@uzkj}e{Qh{qpO4r3{dzs#|Guftj@EE-1#tiX;I=k+7Xf_) zby-wM&<|a|;wpep5Y7Py09EO*O<$-$s}XEm8~}i*1puUE0N@Koq-g-SWB>qM9{@1R z0sy&)yoTe41PidAoi!f#eKY-ivp|VO*tkatF7^LYAPb$`IKfQF7LPj-H#nOY93tH> z^E&<2Kvrjs^Dl@_DIESu)`EnM*Bvjyqg;;QL)~h5irx$z88=G~Q?ZS;dNuY=N~)rP z!{UdRCeJC3|^p@Y^MmSaw|O zqcgnH`mt=(`nwksF1OpdRwptSWL&g;<#Vt5VHL)|uKRA9#vdbF_=0_?$=A_m z@F%rT_!cE|G&D6rw}mnJ1_l_g{vf!~zq1Xc|EB$kXZY!_p79<2K9Yrd(OT{Mb{mHR zIvKG63$q6jt50+|q)^*;#+gBVShnFY)z5nDzRVi4ozb?LsVO@_w2}`s^Nsd45NuI& zhpaC93Xq=!P!5F+t|m_?X^7vZ!l;zS{DCNSO=s(#>vwURfgYrFL(a3IT>5^I1^Q*X z;)RoYq>tsN>gj(j@5Vm}xi@}SsB{RVwp&HMV7`=duNpIAN9DD77D=bx@cS4V-&vPO zs5T{9CdzxiGP{x8MkGN(d=iBWQ`V%DcFT)=J1o)t)H3Td!oc1LAK_<(d3rW6p80;H z#aIj?rO_f63gW^WOh=(%{(Ndk<7FM|l3yoxnrDV*OW1=m7B0}%cYUk@slC;DWXDJ- zbzq}6+noaUhkMwYaDbO3K#1{=500{dhi6mjFD;htRSp}2)l%ASo_Jf3u6|gk06e31F|)O_QAS*Z)CL|=mx)^$~0VZ>4RvI2=-oxLwg~Smc)R? ziic42uNohoj(`9tgiNDgQA$gb7jWL6o!tQebl792!%r&rX2iIfd zwjCA3av?iKJ}2lJ8aH!`0%DB+fVum7^Xn%nzYa~JUtU>O2ppA|Zv3&d&9bUmk59Wz zU5`&9r68bYF9iuIr-eb;&1xz9u|eaG%lNMHX%7WXx1|oiTEa z>5u=TY{rZ?nC7g%a>`NFGkxo57ecI%fnP_0QMi=h$usgg0U#K-lzo4ICX2@=XH+sc z?g*KF0<%eFSi+hfrgUltlJ2H-Z zNgsLbj_fx9j~L$hL?f1QThz~tgbFXL+ABGfgeeV!y6)b(&XBAjndVe&>reu7Pyf=x zBRL88M z)}J=R;=En-&;r8ZUsGq!E5bTZI30ysLn0@>FMiMaxg|*of^qCKqJSvJ z0d>D!A)AgP2XVYFBbH^}&?+aq&<1wvLZ5NZF6a6EL3a$E&MWZh;|@dmkt_PPY2swc z+FZ<9_A}S`S(YM7D3}&ZL*Y5Un?gWzqSNAI^s+V*l+rQuSk)OZIwLDY;!Y&bp^z16 zThcLSRSRV}#c|Rc5zKweZ=hGB3IQi{K@~RcKnG~SYLwuCuxQAB7^l~i#Xn5RuLq`; z3x`vtuuU4;q_@_T}uEHsg z*4`$9M+;xYYtcWMNO>;}oflgzXxnCsdQ{_X0K-Pe{Ar=B2@EJBsZNI*_oq#3H~VyO z1+Z!ok$uzv#fhVV&1M?SNP)52x%dd@WR24 zUs;*7RYd??6)qAX(T^L;oAxHFSVQ#WgHm$*kXwB_I_3T3&DJ&FrTvwioMQ7?w}NWCd{15EH?+j(m|Bqcpc;aWOw>o##G#~R~B zUK2Wj&~kbSK&DkUi_rUF{iq$AEzUzRN$hp^Ov$CFzCgWie8o9+7{CS_K5>u>-XXU?4pmmDU ztF($Uj}mD;7m=xN*ZW;doJicbKz@!Nb9mH-kEnvphMGQMR4LxYt-BP2MOr8}%g{!FKX!$g&pF<7_#5Ou0bt>ToPvvMIf;;DVHCXEYR{K)FG zEnpihSH8kHUn+U2iLU)o$`?z z?v&TgUglV~^_d5mvWG1PRPvjab=@BKopM~Yml!&Z@v1m=-&Bk&>c>i&w<7pjKM_7i z4O-r^YC0m*I}`em?^|CtlgnTzZ-0t#cSAfpe}nN7yeacKvaa!Lc^~0`!h$)k2vJas zVl6|mN7CebXQZr}Ji;q~o8Mu*J6`Z0{e|F3(u}q|9qsQM9biU?3=jY?GB7mVXMovf zWPHL9V`hXlGcny`U|?oou(O3u|5qS1-2Z%F{QnP}&sR_p1myp@;2Itn9qStz0K~?| g>YWb@jw1L*1n7lF2IbBjQV^H`TT4g$QwtyR-{S78%>V!Z From 4c79ea186ff745334756361a46fb550e6d1b5810 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 20 Jan 2026 11:19:50 -0800 Subject: [PATCH 006/200] refactor!: Deprecate `Block.getVars()` (#9574) * refactor!: Deprecate `Block.getVars()` * fix: Fix import path * fix: Simplify test assertions --- blocks/procedures.ts | 20 +++++++++++++++++++- core/block.ts | 9 ++++++++- demos/blockfactory/factory_utils.js | 2 +- generators/dart/procedures.ts | 6 +++--- generators/javascript/procedures.ts | 6 +++--- generators/lua/procedures.ts | 6 +++--- generators/php/procedures.ts | 9 ++++----- generators/python/procedures.ts | 9 ++++----- tests/mocha/blocks/procedures_test.js | 20 ++++++-------------- tests/mocha/test_helpers/procedures.js | 10 ++++++++-- 10 files changed, 59 insertions(+), 38 deletions(-) diff --git a/blocks/procedures.ts b/blocks/procedures.ts index b8bc4fddd..10239fa37 100644 --- a/blocks/procedures.ts +++ b/blocks/procedures.ts @@ -38,6 +38,7 @@ import type { import {Msg} from '../core/msg.js'; import {Names} from '../core/names.js'; import * as Procedures from '../core/procedures.js'; +import * as deprecation from '../core/utils/deprecation.js'; import * as xmlUtils from '../core/utils/xml.js'; import * as Variables from '../core/variables.js'; import type {Workspace} from '../core/workspace.js'; @@ -345,9 +346,17 @@ const PROCEDURE_DEF_COMMON = { /** * Return all variables referenced by this block. * + * @deprecated v13: Use Blockly.libraryBlocks.procedures.getVarModels() + * .map(m => m.getName()) * @returns List of variable names. */ getVars: function (this: ProcedureBlock): string[] { + deprecation.warn( + 'Blockly.libraryBlocks.procedures.getVars()', + 'v13', + 'v14', + 'Blockly.libraryBlocks.procedures.getVarModels().map(model => model.getName())', + ); return this.arguments_; }, /** @@ -1020,9 +1029,17 @@ const PROCEDURE_CALL_COMMON = { /** * Return all variables referenced by this block. * + * @deprecated v13: Use Blockly.libraryBlocks.procedures.getVarModels() + * .map(m => m.getName()) * @returns List of variable names. */ getVars: function (this: CallBlock): string[] { + deprecation.warn( + 'Blockly.libraryBlocks.procedures.getVars()', + 'v13', + 'v14', + 'Blockly.libraryBlocks.procedures.getVarModels().map(model => model.getName())', + ); return this.arguments_; }, /** @@ -1060,7 +1077,8 @@ const PROCEDURE_CALL_COMMON = { if ( def && (def.type !== this.defType_ || - JSON.stringify(def.getVars()) !== JSON.stringify(this.arguments_)) + JSON.stringify(def.getVarModels().map((model) => model.getName())) !== + JSON.stringify(this.arguments_)) ) { // The signatures don't match. def = null; diff --git a/core/block.ts b/core/block.ts index af44facda..69c956029 100644 --- a/core/block.ts +++ b/core/block.ts @@ -50,6 +50,7 @@ import * as registry from './registry.js'; import * as Tooltip from './tooltip.js'; import * as arrayUtils from './utils/array.js'; import {Coordinate} from './utils/coordinate.js'; +import * as deprecation from './utils/deprecation.js'; import * as idGenerator from './utils/idgenerator.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; @@ -1139,9 +1140,16 @@ export class Block { /** * Return all variables referenced by this block. * + * @deprecated v13: Use Blockly.Block.getVarModels().map(m => m.getId()) * @returns List of variable ids. */ getVars(): string[] { + deprecation.warn( + 'Blockly.Block.getVars()', + 'v13', + 'v14', + 'Blockly.Block.getVarModels().map(model => model.getId())', + ); const vars: string[] = []; for (const field of this.getFields()) { if (field.referencesVariables()) { @@ -1155,7 +1163,6 @@ export class Block { * Return all variables referenced by this block. * * @returns List of variable models. - * @internal */ getVarModels(): IVariableModel[] { const vars = []; diff --git a/demos/blockfactory/factory_utils.js b/demos/blockfactory/factory_utils.js index 4731d1ce9..2d51b4c60 100644 --- a/demos/blockfactory/factory_utils.js +++ b/demos/blockfactory/factory_utils.js @@ -964,7 +964,7 @@ FactoryUtils.hasVariableField = function(block) { if (!block) { return false; } - return block.getVars().length > 0; + return block.getVarModels().length > 0; }; /** diff --git a/generators/dart/procedures.ts b/generators/dart/procedures.ts index 8890e713c..245c33773 100644 --- a/generators/dart/procedures.ts +++ b/generators/dart/procedures.ts @@ -56,9 +56,9 @@ export function procedures_defreturn(block: Block, generator: DartGenerator) { } const returnType = returnValue ? 'dynamic' : 'void'; const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { - args[i] = generator.getVariableName(variables[i]); + args[i] = generator.getVariableName(variables[i].getId()); } let code = returnType + @@ -92,7 +92,7 @@ export function procedures_callreturn( // Call a procedure with a return value. const funcName = generator.getProcedureName(block.getFieldValue('NAME')); const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { args[i] = generator.valueToCode(block, 'ARG' + i, Order.NONE) || 'null'; } diff --git a/generators/javascript/procedures.ts b/generators/javascript/procedures.ts index d4a2e2034..8815d870e 100644 --- a/generators/javascript/procedures.ts +++ b/generators/javascript/procedures.ts @@ -58,9 +58,9 @@ export function procedures_defreturn( returnValue = generator.INDENT + 'return ' + returnValue + ';\n'; } const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { - args[i] = generator.getVariableName(variables[i]); + args[i] = generator.getVariableName(variables[i].getId()); } let code = 'function ' + @@ -93,7 +93,7 @@ export function procedures_callreturn( // Call a procedure with a return value. const funcName = generator.getProcedureName(block.getFieldValue('NAME')); const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { args[i] = generator.valueToCode(block, 'ARG' + i, Order.NONE) || 'null'; } diff --git a/generators/lua/procedures.ts b/generators/lua/procedures.ts index bd78ef7a6..113a0b180 100644 --- a/generators/lua/procedures.ts +++ b/generators/lua/procedures.ts @@ -60,9 +60,9 @@ export function procedures_defreturn( branch = ''; } const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { - args[i] = generator.getVariableName(variables[i]); + args[i] = generator.getVariableName(variables[i].getId()); } let code = 'function ' + @@ -95,7 +95,7 @@ export function procedures_callreturn( // Call a procedure with a return value. const funcName = generator.getProcedureName(block.getFieldValue('NAME')); const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { args[i] = generator.valueToCode(block, 'ARG' + i, Order.NONE) || 'nil'; } diff --git a/generators/php/procedures.ts b/generators/php/procedures.ts index c881da281..2bf353096 100644 --- a/generators/php/procedures.ts +++ b/generators/php/procedures.ts @@ -26,8 +26,7 @@ export function procedures_defreturn(block: Block, generator: PhpGenerator) { const usedVariables = Variables.allUsedVarModels(workspace) || []; for (const variable of usedVariables) { const varName = variable.getName(); - // getVars returns parameter names, not ids, for procedure blocks - if (!block.getVars().includes(varName)) { + if (!block.getVarModels().includes(variable)) { globals.push(generator.getVariableName(varName)); } } @@ -80,9 +79,9 @@ export function procedures_defreturn(block: Block, generator: PhpGenerator) { returnValue = generator.INDENT + 'return ' + returnValue + ';\n'; } const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { - args[i] = generator.getVariableName(variables[i]); + args[i] = generator.getVariableName(variables[i].getId()); } let code = 'function ' + @@ -116,7 +115,7 @@ export function procedures_callreturn( // Call a procedure with a return value. const funcName = generator.getProcedureName(block.getFieldValue('NAME')); const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { args[i] = generator.valueToCode(block, 'ARG' + i, Order.NONE) || 'null'; } diff --git a/generators/python/procedures.ts b/generators/python/procedures.ts index 9c00a7d50..e8e1d4dc7 100644 --- a/generators/python/procedures.ts +++ b/generators/python/procedures.ts @@ -26,8 +26,7 @@ export function procedures_defreturn(block: Block, generator: PythonGenerator) { const usedVariables = Variables.allUsedVarModels(workspace) || []; for (const variable of usedVariables) { const varName = variable.getName(); - // getVars returns parameter names, not ids, for procedure blocks - if (!block.getVars().includes(varName)) { + if (!block.getVarModels().includes(variable)) { globals.push(generator.getVariableName(varName)); } } @@ -82,9 +81,9 @@ export function procedures_defreturn(block: Block, generator: PythonGenerator) { branch = generator.PASS; } const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { - args[i] = generator.getVariableName(variables[i]); + args[i] = generator.getVariableName(variables[i].getId()); } let code = 'def ' + @@ -117,7 +116,7 @@ export function procedures_callreturn( // Call a procedure with a return value. const funcName = generator.getProcedureName(block.getFieldValue('NAME')); const args = []; - const variables = block.getVars(); + const variables = block.getVarModels(); for (let i = 0; i < variables.length; i++) { args[i] = generator.valueToCode(block, 'ARG' + i, Order.NONE) || 'None'; } diff --git a/tests/mocha/blocks/procedures_test.js b/tests/mocha/blocks/procedures_test.js index 6921ef1a4..6b249f5af 100644 --- a/tests/mocha/blocks/procedures_test.js +++ b/tests/mocha/blocks/procedures_test.js @@ -1972,22 +1972,14 @@ suite('Procedures', function () { this.clock.runAll(); } function assertArgs(argArray) { - assert.equal( - this.defBlock.getVars().length, - argArray.length, - 'Expected the def to have the right number of arguments', + assert.deepEqual( + this.defBlock.getVarModels().map((m) => m.getName()), + argArray, ); - for (let i = 0; i < argArray.length; i++) { - assert.equal(this.defBlock.getVars()[i], argArray[i]); - } - assert.equal( - this.callBlock.getVars().length, - argArray.length, - 'Expected the call to have the right number of arguments', + assert.deepEqual( + this.callBlock.getVarModels().map((m) => m.getName()), + argArray, ); - for (let i = 0; i < argArray.length; i++) { - assert.equal(this.callBlock.getVars()[i], argArray[i]); - } } test('Simple Add Arg', async function () { const args = ['arg1']; diff --git a/tests/mocha/test_helpers/procedures.js b/tests/mocha/test_helpers/procedures.js index 16ef97335..57104be4c 100644 --- a/tests/mocha/test_helpers/procedures.js +++ b/tests/mocha/test_helpers/procedures.js @@ -47,7 +47,10 @@ function assertCallBlockArgsStructure(callBlock, args) { 'Call block consts did not match expected.', ); } - assert.sameOrderedMembers(callBlock.getVars(), args); + assert.sameOrderedMembers( + callBlock.getVarModels().map((model) => model.getName()), + args, + ); } /** @@ -104,7 +107,10 @@ export function assertDefBlockStructure( ); } - assert.sameOrderedMembers(defBlock.getVars(), args); + assert.sameOrderedMembers( + defBlock.getVarModels().map((model) => model.getName()), + args, + ); assertBlockVarModels(defBlock, varIds); } From 589e05d4d104f8ad275791af4887aae32921a426 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 26 Feb 2026 11:27:33 -0800 Subject: [PATCH 007/200] fix!: Normalize Zelos connection indicators (#9565) * fix: Normalize Zelos connection indicators * feat: Add `IPathObject.updateReplacing()` --- packages/blockly/core/block_svg.ts | 26 ++--- .../core/insertion_marker_previewer.ts | 8 ++ packages/blockly/core/rendered_connection.ts | 1 + .../core/renderers/common/i_path_object.ts | 9 ++ .../core/renderers/common/path_object.ts | 24 ++--- .../blockly/core/renderers/common/renderer.ts | 7 +- .../blockly/core/renderers/zelos/constants.ts | 99 ------------------- .../core/renderers/zelos/path_object.ts | 21 ---- .../blockly/core/renderers/zelos/renderer.ts | 15 +++ packages/blockly/core/theme.ts | 2 - 10 files changed, 51 insertions(+), 161 deletions(-) diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 83af5188e..9ed093213 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -791,6 +791,13 @@ export class BlockSvg } } + /** + * Returns whether or not this block is currently being dragged. + */ + isDragging() { + return this.dragging; + } + /** * Set whether this block is movable or not. * @@ -1738,24 +1745,7 @@ export class BlockSvg * @internal */ fadeForReplacement(add: boolean) { - // TODO (7204): Remove these internal methods. - (this.pathObject as AnyDuringMigration).updateReplacementFade(add); - } - - /** - * Visual effect to show that if the dragging block is dropped it will connect - * to this input. - * - * @param conn The connection on the input to highlight. - * @param add True if highlighting should be added. - * @internal - */ - highlightShapeForInput(conn: RenderedConnection, add: boolean) { - // TODO (7204): Remove these internal methods. - (this.pathObject as AnyDuringMigration).updateShapeForInputHighlight( - conn, - add, - ); + this.pathObject.updateReplacing?.(add); } /** diff --git a/packages/blockly/core/insertion_marker_previewer.ts b/packages/blockly/core/insertion_marker_previewer.ts index 8b5b82468..2a31327ce 100644 --- a/packages/blockly/core/insertion_marker_previewer.ts +++ b/packages/blockly/core/insertion_marker_previewer.ts @@ -92,6 +92,10 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer { staticConn.highlight(); } + if (this.workspace.getRenderer().shouldHighlightConnection(draggedConn)) { + draggedConn.highlight(); + } + this.draggedConn = draggedConn; this.staticConn = staticConn; } finally { @@ -224,6 +228,10 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer { this.staticConn.unhighlight(); this.staticConn = null; } + if (this.draggedConn) { + this.draggedConn.unhighlight(); + this.draggedConn = null; + } if (this.fadedBlock) { this.fadedBlock.fadeForReplacement(false); this.fadedBlock = null; diff --git a/packages/blockly/core/rendered_connection.ts b/packages/blockly/core/rendered_connection.ts index af1faa958..d069cc87b 100644 --- a/packages/blockly/core/rendered_connection.ts +++ b/packages/blockly/core/rendered_connection.ts @@ -331,6 +331,7 @@ export class RenderedConnection const highlightSvg = this.findHighlightSvg(); if (highlightSvg) { highlightSvg.style.display = ''; + highlightSvg.parentElement?.appendChild(highlightSvg); } } diff --git a/packages/blockly/core/renderers/common/i_path_object.ts b/packages/blockly/core/renderers/common/i_path_object.ts index a68c3a411..a7379bbea 100644 --- a/packages/blockly/core/renderers/common/i_path_object.ts +++ b/packages/blockly/core/renderers/common/i_path_object.ts @@ -106,4 +106,13 @@ export interface IPathObject { * @param blockStyle The block style to use. */ setStyle?(blockStyle: BlockStyle): void; + + /** + * Add or remove styling indicating that a block will be bumped out and + * replaced by another block that is mid-move. + * + * @param replacing True if the block is at risk of being replaced, false + * otherwise. + */ + updateReplacing?(replacing: boolean): void; } diff --git a/packages/blockly/core/renderers/common/path_object.ts b/packages/blockly/core/renderers/common/path_object.ts index f6291b9f0..e6cb4962f 100644 --- a/packages/blockly/core/renderers/common/path_object.ts +++ b/packages/blockly/core/renderers/common/path_object.ts @@ -7,7 +7,6 @@ // Former goog.module ID: Blockly.blockRendering.PathObject import type {BlockSvg} from '../../block_svg.js'; -import type {Connection} from '../../connection.js'; import {RenderedConnection} from '../../rendered_connection.js'; import type {BlockStyle} from '../../theme.js'; import {Coordinate} from '../../utils/coordinate.js'; @@ -193,25 +192,14 @@ export class PathObject implements IPathObject { } /** - * Add or remove styling that shows that if the dragging block is dropped, - * this block will be replaced. If a shadow block, it will disappear. - * Otherwise it will bump. + * Add or remove styling indicating that a block will be bumped out and + * replaced by another block that is mid-move. * - * @param enable True if styling should be added. + * @param replacing True if the block is at risk of being replaced, false + * otherwise. */ - updateReplacementFade(enable: boolean) { - this.setClass_('blocklyReplaceable', enable); - } - - /** - * Add or remove styling that shows that if the dragging block is dropped, - * this block will be connected to the input. - * - * @param _conn The connection on the input to highlight. - * @param _enable True if styling should be added. - */ - updateShapeForInputHighlight(_conn: Connection, _enable: boolean) { - // NOOP + updateReplacing(replacing: boolean) { + this.setClass_('blocklyReplaceable', replacing); } /** Adds the given path as a connection highlight for the given connection. */ diff --git a/packages/blockly/core/renderers/common/renderer.ts b/packages/blockly/core/renderers/common/renderer.ts index 5b7e687c2..a05dea79e 100644 --- a/packages/blockly/core/renderers/common/renderer.ts +++ b/packages/blockly/core/renderers/common/renderer.ts @@ -11,6 +11,7 @@ import type {BlockSvg} from '../../block_svg.js'; import {Connection} from '../../connection.js'; import {ConnectionType} from '../../connection_type.js'; import type {IRegistrable} from '../../interfaces/i_registrable.js'; +import type {RenderedConnection} from '../../rendered_connection.js'; import type {BlockStyle, Theme} from '../../theme.js'; import {ConstantProvider} from './constants.js'; import {Drawer} from './drawer.js'; @@ -188,11 +189,11 @@ export class Renderer implements IRegistrable { /** * Determine whether or not to highlight a connection. * - * @param _conn The connection to determine whether or not to highlight. + * @param connection The connection to determine whether or not to highlight. * @returns True if we should highlight the connection. */ - shouldHighlightConnection(_conn: Connection): boolean { - return true; + shouldHighlightConnection(connection: RenderedConnection): boolean { + return !connection.getSourceBlock().isDragging(); } /** diff --git a/packages/blockly/core/renderers/zelos/constants.ts b/packages/blockly/core/renderers/zelos/constants.ts index 8cd36e025..ac06de501 100644 --- a/packages/blockly/core/renderers/zelos/constants.ts +++ b/packages/blockly/core/renderers/zelos/constants.ts @@ -105,12 +105,6 @@ export class ConstantProvider extends BaseConstantProvider { /** The size of the selected glow. */ SELECTED_GLOW_SIZE = 0.5; - /** The replacement glow colour. */ - REPLACEMENT_GLOW_COLOUR = '#fff200'; - - /** The size of the selected glow. */ - REPLACEMENT_GLOW_SIZE = 2; - /** * The ID of the selected glow filter, or the empty string if no filter is * set. @@ -122,17 +116,6 @@ export class ConstantProvider extends BaseConstantProvider { */ private selectedGlowFilter: SVGElement | null = null; - /** - * The ID of the replacement glow filter, or the empty string if no filter - * is set. - */ - replacementGlowFilterId = ''; - - /** - * The element to use for a replacement glow, or null if not set. - */ - private replacementGlowFilter: SVGElement | null = null; - /** * The object containing information about the hexagon used for a boolean * reporter block. Null before init is called. @@ -269,16 +252,6 @@ export class ConstantProvider extends BaseConstantProvider { selectedGlowSize && !isNaN(selectedGlowSize) ? selectedGlowSize : this.SELECTED_GLOW_SIZE; - this.REPLACEMENT_GLOW_COLOUR = - theme.getComponentStyle('replacementGlowColour') || - this.REPLACEMENT_GLOW_COLOUR; - const replacementGlowSize = Number( - theme.getComponentStyle('replacementGlowSize'), - ); - this.REPLACEMENT_GLOW_SIZE = - replacementGlowSize && !isNaN(replacementGlowSize) - ? replacementGlowSize - : this.REPLACEMENT_GLOW_SIZE; } override dispose() { @@ -286,9 +259,6 @@ export class ConstantProvider extends BaseConstantProvider { if (this.selectedGlowFilter) { dom.removeNode(this.selectedGlowFilter); } - if (this.replacementGlowFilter) { - dom.removeNode(this.replacementGlowFilter); - } } override makeStartHat() { @@ -740,67 +710,6 @@ export class ConstantProvider extends BaseConstantProvider { this.selectedGlowFilterId = selectedGlowFilter.id; this.selectedGlowFilter = selectedGlowFilter; - // Using a dilate distorts the block shape. - // Instead use a gaussian blur, and then set all alpha to 1 with a transfer. - const replacementGlowFilter = dom.createSvgElement( - Svg.FILTER, - { - 'id': 'blocklyReplacementGlowFilter' + this.randomIdentifier, - 'height': '160%', - 'width': '180%', - 'y': '-30%', - 'x': '-40%', - }, - defs, - ); - dom.createSvgElement( - Svg.FEGAUSSIANBLUR, - {'in': 'SourceGraphic', 'stdDeviation': this.REPLACEMENT_GLOW_SIZE}, - replacementGlowFilter, - ); - // Set all gaussian blur pixels to 1 opacity before applying flood - const replacementComponentTransfer = dom.createSvgElement( - Svg.FECOMPONENTTRANSFER, - {'result': 'outBlur'}, - replacementGlowFilter, - ); - dom.createSvgElement( - Svg.FEFUNCA, - {'type': 'table', 'tableValues': '0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1'}, - replacementComponentTransfer, - ); - // Color the highlight - dom.createSvgElement( - Svg.FEFLOOD, - { - 'flood-color': this.REPLACEMENT_GLOW_COLOUR, - 'flood-opacity': 1, - 'result': 'outColor', - }, - replacementGlowFilter, - ); - dom.createSvgElement( - Svg.FECOMPOSITE, - { - 'in': 'outColor', - 'in2': 'outBlur', - 'operator': 'in', - 'result': 'outGlow', - }, - replacementGlowFilter, - ); - dom.createSvgElement( - Svg.FECOMPOSITE, - { - 'in': 'SourceGraphic', - 'in2': 'outGlow', - 'operator': 'over', - }, - replacementGlowFilter, - ); - this.replacementGlowFilterId = replacementGlowFilter.id; - this.replacementGlowFilter = replacementGlowFilter; - if (injectionDivIfIsParent) { // If this renderer is for the parent workspace, add CSS variables scoped // to the injection div referencing the created patterns so that CSS can @@ -809,10 +718,6 @@ export class ConstantProvider extends BaseConstantProvider { '--blocklySelectedGlowFilter', `url(#${this.selectedGlowFilterId})`, ); - injectionDivIfIsParent.style.setProperty( - '--blocklyReplacementGlowFilter', - `url(#${this.replacementGlowFilterId})`, - ); } } @@ -904,10 +809,6 @@ export class ConstantProvider extends BaseConstantProvider { `fill: none;`, `filter: var(--blocklySelectedGlowFilter);`, `}`, - - `${selector} .blocklyReplaceable>.blocklyPath {`, - `filter: var(--blocklyReplacementGlowFilter);`, - `}`, ]; } } diff --git a/packages/blockly/core/renderers/zelos/path_object.ts b/packages/blockly/core/renderers/zelos/path_object.ts index 3c304fd6b..6484be4e0 100644 --- a/packages/blockly/core/renderers/zelos/path_object.ts +++ b/packages/blockly/core/renderers/zelos/path_object.ts @@ -7,7 +7,6 @@ // Former goog.module ID: Blockly.zelos.PathObject 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 dom from '../../utils/dom.js'; @@ -113,26 +112,6 @@ export class PathObject extends BasePathObject { } } - override updateReplacementFade(enable: boolean) { - this.setClass_('blocklyReplaceable', enable); - } - - override updateShapeForInputHighlight(conn: Connection, enable: boolean) { - const name = conn.getParentInput()!.name; - const outlinePath = this.getOutlinePath(name); - if (!outlinePath) { - return; - } - if (enable) { - outlinePath.setAttribute( - 'filter', - 'url(#' + this.constants.replacementGlowFilterId + ')', - ); - } else { - outlinePath.removeAttribute('filter'); - } - } - /** * Method that's called when the drawer is about to draw the block. */ diff --git a/packages/blockly/core/renderers/zelos/renderer.ts b/packages/blockly/core/renderers/zelos/renderer.ts index 367d96faf..98ad5e741 100644 --- a/packages/blockly/core/renderers/zelos/renderer.ts +++ b/packages/blockly/core/renderers/zelos/renderer.ts @@ -7,6 +7,8 @@ // Former goog.module ID: Blockly.zelos.Renderer import type {BlockSvg} from '../../block_svg.js'; +import {ConnectionType} from '../../connection_type.js'; +import type {RenderedConnection} from '../../rendered_connection.js'; import type {BlockStyle} from '../../theme.js'; import * as blockRendering from '../common/block_rendering.js'; import type {RenderInfo as BaseRenderInfo} from '../common/info.js'; @@ -86,6 +88,19 @@ export class Renderer extends BaseRenderer { override getConstants(): ConstantProvider { return this.constants_; } + + /** + * Determine whether or not to highlight a connection. + * + * @param connection The connection to determine whether or not to highlight. + * @returns True if we should highlight the connection. + */ + override shouldHighlightConnection(connection: RenderedConnection): boolean { + return ( + super.shouldHighlightConnection(connection) || + connection.type === ConnectionType.INPUT_VALUE + ); + } } blockRendering.register('zelos', Renderer); diff --git a/packages/blockly/core/theme.ts b/packages/blockly/core/theme.ts index e52eb1da9..023aee1fc 100644 --- a/packages/blockly/core/theme.ts +++ b/packages/blockly/core/theme.ts @@ -215,8 +215,6 @@ export namespace Theme { cursorColour?: string; selectedGlowColour?: string; selectedGlowOpacity?: number; - replacementGlowColour?: string; - replacementGlowOpacity?: number; } export interface FontStyle { From 26d035177ce573d6e83c91a9d8dc738a05d61314 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 26 Feb 2026 13:36:33 -0800 Subject: [PATCH 008/200] chore!: Update supported Node versions (#9600) * chore!: Update supported Node versions * fix: Remove references to not-yet-available Node 26 * chore: Revert formatting change --- .github/workflows/browser_test.yml | 4 ++-- .github/workflows/build.yml | 10 +++++----- packages/blockly/package.json | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/browser_test.yml b/.github/workflows/browser_test.yml index 427a0963a..af7ae73ac 100644 --- a/.github/workflows/browser_test.yml +++ b/.github/workflows/browser_test.yml @@ -21,14 +21,14 @@ jobs: # TODO (#2114): re-enable osx build. # os: [ubuntu-latest, macos-latest] os: [macos-latest] - node-version: [18.x, 20.x] + node-version: [22.x, 24.x] # See supported Node.js release schedule at # https://nodejs.org/en/about/releases/ defaults: run: working-directory: ./packages/blockly - + steps: - uses: actions/checkout@v5 with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0cd08796..21e4db42a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: # TODO (#2114): re-enable osx build. # os: [ubuntu-latest, macos-latest] os: [ubuntu-latest] - node-version: [18.x, 20.x, 22.x, 24.x] + node-version: [22.x, 24.x] # See supported Node.js release schedule at # https://nodejs.org/en/about/releases/ @@ -57,10 +57,10 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Use Node.js 20.x + - name: Use Node.js 24.x uses: actions/setup-node@v5 with: - node-version: 20.x + node-version: 24.x - name: Npm Install run: npm install @@ -74,10 +74,10 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Use Node.js 20.x + - name: Use Node.js 24.x uses: actions/setup-node@v5 with: - node-version: 20.x + node-version: 24.x - name: Npm Install run: npm install diff --git a/packages/blockly/package.json b/packages/blockly/package.json index 3cf6c6d9c..446a79533 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -151,6 +151,6 @@ "jsdom": "26.1.0" }, "engines": { - "node": ">=18" + "node": ">=22" } } From 81c2ed6ed1f7ee3edf26581d883f68a8cd0f3d8b Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 2 Mar 2026 12:28:19 -0800 Subject: [PATCH 009/200] chore(deps): Update Closure compiler to the latest version (#9607) --- package-lock.json | 70 ++++++++++++++++++++++++++++++----- packages/blockly/package.json | 2 +- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index c3a0912da..91b065d62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -739,12 +739,14 @@ } }, "node_modules/google-closure-compiler": { - "version": "20260114.0.0", + "version": "20260225.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20260225.0.0.tgz", + "integrity": "sha512-qUu5r4DNRUJVd1FOkigJxfK1XJWfJdUs+AWc5ArehYavgCFBJcVsa5C9spqrJEua7+xtBSXuC2J2ggS5fEpaKg==", "dev": true, "license": "Apache-2.0", "dependencies": { "chalk": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 <5.6.1 || ^5.6.2 >5.6.1", - "google-closure-compiler-java": "^20260114.0.0", + "google-closure-compiler-java": "^20260225.0.0", "minimist": "^1.0.0", "vinyl": "^3.0.1", "vinyl-sourcemaps-apply": "^0.2.0" @@ -756,19 +758,52 @@ "node": ">=18" }, "optionalDependencies": { - "google-closure-compiler-linux": "^20260114.0.0", - "google-closure-compiler-linux-arm64": "^20260114.0.0", - "google-closure-compiler-macos": "^20260114.0.0", - "google-closure-compiler-windows": "^20260114.0.0" + "google-closure-compiler-linux": "^20260225.0.0", + "google-closure-compiler-linux-arm64": "^20260225.0.0", + "google-closure-compiler-macos": "^20260225.0.0", + "google-closure-compiler-windows": "^20260225.0.0" } }, "node_modules/google-closure-compiler-java": { - "version": "20260114.0.0", + "version": "20260225.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20260225.0.0.tgz", + "integrity": "sha512-sxxHBpF0ph878cjozdqpEBgR/rv7fN5aYgDgNXcHdNWPs5Qr5uQaV3rdViqi/vMpqC+CYMft7tuY6375IfkfOg==", "dev": true, "license": "Apache-2.0" }, + "node_modules/google-closure-compiler-linux": { + "version": "20260225.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20260225.0.0.tgz", + "integrity": "sha512-8rPCjLuqvbFUL6Pk+lrrhj9BE9jFFymx2bMHJjQQtuT0NYHBBdy6zm7tN++zp3zPOOUupBizNsWLj2pkkP24Ag==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/google-closure-compiler-linux-arm64": { + "version": "20260225.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20260225.0.0.tgz", + "integrity": "sha512-L7/kXqh5wmpnU44ierzHMevpaQgZHTIwp9LPPJLAvUWJ/K54HUGcz4sUhCbUzAbXE96YzQ+q87dG9ueBkZqffA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/google-closure-compiler-macos": { - "version": "20260114.0.0", + "version": "20260225.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20260225.0.0.tgz", + "integrity": "sha512-QTxlYCTbyE80OyiNmxf0JAxiKKNECIQ1jINTG6ezKpBefERvoLkezhsjlfxm/KXE6YwCWXd6+puZ9vxnoWUMqQ==", "cpu": [ "arm64" ], @@ -779,6 +814,21 @@ "darwin" ] }, + "node_modules/google-closure-compiler-windows": { + "version": "20260225.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20260225.0.0.tgz", + "integrity": "sha512-tDpH19bEFqo7R+GxDWvvpeBg/HfPpCohCNG6a0Jc9im40zfVKa0YwJxuStXk0Tr4T9mIg7OnnEfVla5aeJkLzg==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1333,7 +1383,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260114.0.0", + "google-closure-compiler": "^20260225.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", @@ -1361,7 +1411,7 @@ "yargs": "^17.2.1" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "packages/blockly/node_modules/@aashutoshrathi/word-wrap": { diff --git a/packages/blockly/package.json b/packages/blockly/package.json index f96441269..87f2d4e98 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -120,7 +120,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260114.0.0", + "google-closure-compiler": "^20260225.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", From da1db45dd257b2e033ef5fb8e4bae2f48d0da83b Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 3 Mar 2026 11:51:07 -0800 Subject: [PATCH 010/200] feat!: Allow moving blocks, comments and bubbles using the keyboard (#9593) * refactor!: Update dragging APIs. * fix: Fix bug that caused drags to always result in deletion * refactor: Clean up block drag handling with new API * chore: Format files * feat: Add an `isBoundedElement` type predicate * feat: Make `Bubble` implement `IBoundedElement` * fix: Fix jumping/scrolling when moving blocks * feat: Add a `KeyboardMover` * feat: Update the `BlockDragStrategy` to support constrained movement * feat: Register keyboard shortcuts to drive movement * feat: Display a move indicator on items that are being moved * fix: Reenable move hints * fix: Fix bugs that caused elements to be mispositioned by keyboard moves at non-default zoom levels * fix: Fix a bug that caused certain connections to be visited out of order * fix: Fix a bug that caused blocks to become disconnected during constrained moves * test: Add tests for keyboard-driven movement * chore: Add exports * chore: Run formatter * chore: Make the linter happy * chore: Update closure compiler * fix: Fix test suite on non-macOS * fix: Don't scroll in response to arrow keys while moving items * fix: Fix positioning of move indicator in RTL * refactor: Clarify return types of drag-start related methods * refactor: Make the `KeyboardMover` a singleton * fix: Fix import path * refactor: Remove `WorkspaceSvg.keyboardMoveInProgress` * fix: Fix tests * chore: Remove unused import * chore: Clean up comments and names * refactor: Make `IDraggable` extend `IBoundedElement` and `ISelectable` * chore: Rename test blocks file for move mode * refactor: Make block connection offset a constant * refactor: Export `KeyboardMover` class with a static instance * fix: Use Command and Control as modifiers for unconstrained move mode * fix: Fix test failures in CI * feat: Support allowlisting keyboard shortcuts for mid-move use --- package-lock.json | 131 +-- packages/blockly/core/block_svg.ts | 29 +- packages/blockly/core/blockly.ts | 13 +- packages/blockly/core/bubbles/bubble.ts | 36 +- .../core/bubbles/mini_workspace_bubble.ts | 16 +- .../comments/rendered_workspace_comment.ts | 4 +- .../core/comments/workspace_comment.ts | 2 +- .../core/dragging/block_drag_strategy.ts | 370 ++++++-- .../core/dragging/bubble_drag_strategy.ts | 11 +- .../core/dragging/comment_drag_strategy.ts | 13 +- packages/blockly/core/dragging/dragger.ts | 115 ++- packages/blockly/core/hints.ts | 64 ++ .../core/interfaces/i_bounded_element.ts | 13 + packages/blockly/core/interfaces/i_bubble.ts | 9 +- .../blockly/core/interfaces/i_draggable.ts | 46 +- packages/blockly/core/interfaces/i_dragger.ts | 39 +- .../core/keyboard_nav/keyboard_mover.ts | 329 +++++++ .../core/keyboard_nav/move_indicator.ts | 81 ++ packages/blockly/core/shortcut_items.ts | 125 ++- packages/blockly/core/workspace_svg.ts | 46 +- packages/blockly/msg/json/en.json | 3 +- packages/blockly/msg/json/qqq.json | 1 + packages/blockly/msg/messages.js | 5 +- packages/blockly/tests/mocha/index.html | 1 + .../tests/mocha/keyboard_movement_test.js | 861 ++++++++++++++++++ .../mocha/test_helpers/move_test_blocks.js | 528 +++++++++++ .../tests/mocha/test_helpers/p5_blocks.js | 358 ++++++++ 27 files changed, 2959 insertions(+), 290 deletions(-) create mode 100644 packages/blockly/core/hints.ts create mode 100644 packages/blockly/core/keyboard_nav/keyboard_mover.ts create mode 100644 packages/blockly/core/keyboard_nav/move_indicator.ts create mode 100644 packages/blockly/tests/mocha/keyboard_movement_test.js create mode 100644 packages/blockly/tests/mocha/test_helpers/move_test_blocks.js create mode 100644 packages/blockly/tests/mocha/test_helpers/p5_blocks.js diff --git a/package-lock.json b/package-lock.json index 91b065d62..ce9b99b05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,14 +42,14 @@ } }, "node_modules/@commitlint/cli": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.1.tgz", - "integrity": "sha512-uuFKKpc7OtQM+6SRqT+a4kV818o1pS+uvv/gsRhyX7g4x495jg+Q7P0+O9VNGyLXBYP0syksS7gMRDJKcekr6A==", + "version": "20.4.2", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.2.tgz", + "integrity": "sha512-YjYSX2yj/WsVoxh9mNiymfFS2ADbg2EK4+1WAsMuckwKMCqJ5PDG0CJU/8GvmHWcv4VRB2V02KqSiecRksWqZQ==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/format": "^20.4.0", - "@commitlint/lint": "^20.4.1", + "@commitlint/lint": "^20.4.2", "@commitlint/load": "^20.4.0", "@commitlint/read": "^20.4.0", "@commitlint/types": "^20.4.0", @@ -64,9 +64,9 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.1.tgz", - "integrity": "sha512-0YUvIeBtpi86XriqrR+TCULVFiyYTIOEPjK7tTRMxjcBm1qlzb+kz7IF2WxL6Fq5DaundG8VO37BNgMkMTBwqA==", + "version": "20.4.2", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.2.tgz", + "integrity": "sha512-rwkTF55q7Q+6dpSKUmJoScV0f3EpDlWKw2UPzklkLS4o5krMN1tPWAVOgHRtyUTMneIapLeQwaCjn44Td6OzBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -148,15 +148,15 @@ } }, "node_modules/@commitlint/lint": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.1.tgz", - "integrity": "sha512-g94LrGl/c6UhuhDQqNqU232aslLEN2vzc7MPfQTHzwzM4GHNnEAwVWWnh0zX8S5YXecuLXDwbCsoGwmpAgPWKA==", + "version": "20.4.2", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.2.tgz", + "integrity": "sha512-buquzNRtFng6xjXvBU1abY/WPEEjCgUipNQrNmIWe8QuJ6LWLtei/LDBAzEe5ASm45+Q9L2Xi3/GVvlj50GAug==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/is-ignored": "^20.4.1", "@commitlint/parse": "^20.4.1", - "@commitlint/rules": "^20.4.1", + "@commitlint/rules": "^20.4.2", "@commitlint/types": "^20.4.0" }, "engines": { @@ -245,9 +245,9 @@ } }, "node_modules/@commitlint/rules": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.1.tgz", - "integrity": "sha512-WtqypKEPbQEuJwJS4aKs0OoJRBKz1HXPBC9wRtzVNH68FLhPWzxXlF09hpUXM9zdYTpm4vAdoTGkWiBgQ/vL0g==", + "version": "20.4.2", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.2.tgz", + "integrity": "sha512-oz83pnp5Yq6uwwTAabuVQPNlPfeD2Y5ZjMb7Wx8FSUlu4sLYJjbBWt8031Z0osCFPfHzAwSYrjnfDFKtuSMdKg==", "dev": true, "license": "MIT", "dependencies": { @@ -316,10 +316,21 @@ "node": "20 || >=22" } }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -335,8 +346,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -345,8 +354,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -361,8 +368,6 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, @@ -405,8 +410,6 @@ }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -415,6 +418,8 @@ }, "node_modules/chalk": { "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", "engines": { @@ -426,8 +431,6 @@ }, "node_modules/cliui": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", "dependencies": { @@ -449,8 +452,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -462,8 +463,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, @@ -479,9 +478,9 @@ } }, "node_modules/conventional-changelog-angular": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.1.0.tgz", - "integrity": "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.2.0.tgz", + "integrity": "sha512-4YB1zEXqB17oBI8yRsAs1T+ZhbdsOgJqkl6Trz+GXt/eKf1e4jnA0oW+sOd9BEENzEViuNW0DNoFFjSf3CeC5Q==", "dev": true, "license": "ISC", "dependencies": { @@ -492,9 +491,9 @@ } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.1.0.tgz", - "integrity": "sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.2.0.tgz", + "integrity": "sha512-fCf+ODjseueTV09wVBoC0HXLi3OyuBJ+HfE3L63Khxqnr99f9nUcnQh3a15lCWHlGLihyZShW/mVVkBagr9JvQ==", "dev": true, "license": "ISC", "dependencies": { @@ -505,12 +504,13 @@ } }, "node_modules/conventional-commits-parser": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.1.tgz", - "integrity": "sha512-20pyHgnO40rvfI0NGF/xiEoFMkXDtkF8FwHvk5BokoFoCuTQRI8vrNCNFWUOfuolKJMm1tPCHc8GgYEtr1XRNA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz", + "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==", "dev": true, "license": "MIT", "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { @@ -521,9 +521,9 @@ } }, "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -593,8 +593,6 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, @@ -620,8 +618,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { @@ -638,8 +634,6 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, @@ -650,8 +644,6 @@ }, "node_modules/fast-uri": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -667,8 +659,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "license": "ISC", "engines": { @@ -679,6 +669,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "deprecated": "This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.", "dev": true, "license": "MIT", "dependencies": { @@ -831,8 +822,6 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -848,8 +837,6 @@ }, "node_modules/import-fresh/node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -858,8 +845,6 @@ }, "node_modules/import-meta-resolve": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, "license": "MIT", "funding": { @@ -886,8 +871,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -906,8 +889,6 @@ }, "node_modules/is-plain-obj": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, "license": "MIT", "engines": { @@ -936,8 +917,6 @@ }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -956,8 +935,6 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -1068,8 +1045,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1135,8 +1110,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", "engines": { @@ -1145,8 +1118,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", "engines": { @@ -1204,8 +1175,6 @@ }, "node_modules/split2": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, "license": "ISC", "engines": { @@ -1224,8 +1193,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -1239,8 +1206,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -1278,6 +1243,8 @@ }, "node_modules/vinyl": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "license": "MIT", "dependencies": { @@ -1300,8 +1267,6 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1318,8 +1283,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, "license": "ISC", "engines": { @@ -1328,8 +1291,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", "dependencies": { @@ -1347,8 +1308,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", "engines": { diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index f567f041a..0bdb726b8 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -43,7 +43,11 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {ICopyable} from './interfaces/i_copyable.js'; import {IDeletable} from './interfaces/i_deletable.js'; -import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js'; +import type { + DragDisposition, + IDragStrategy, + IDraggable, +} from './interfaces/i_draggable.js'; import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {IIcon} from './interfaces/i_icon.js'; @@ -1810,18 +1814,21 @@ export class BlockSvg } /** Starts a drag on the block. */ - startDrag(e?: PointerEvent): void { - this.dragStrategy.startDrag(e); + startDrag(e?: PointerEvent | KeyboardEvent) { + return this.dragStrategy.startDrag(e); } /** Drags the block to the given location. */ - drag(newLoc: Coordinate, e?: PointerEvent): void { + drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void { this.dragStrategy.drag(newLoc, e); } /** Ends the drag on the block. */ - endDrag(e?: PointerEvent): void { - this.dragStrategy.endDrag(e); + endDrag( + e: PointerEvent | KeyboardEvent | undefined, + disposition: DragDisposition, + ): void { + this.dragStrategy.endDrag(e, disposition); } /** Moves the block back to where it was at the start of a drag. */ @@ -1880,9 +1887,13 @@ export class BlockSvg /** See IFocusableNode.onNodeFocus. */ onNodeFocus(): void { this.select(); - this.workspace.scrollBoundsIntoView( - this.getBoundingRectangleWithoutChildren(), - ); + if (getFocusManager().getFocusedNode() !== this) { + renderManagement.finishQueuedRenders().then(() => { + this.workspace.scrollBoundsIntoView( + this.getBoundingRectangleWithoutChildren(), + ); + }); + } } /** See IFocusableNode.onNodeBlur. */ diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 99112d790..7377ff909 100644 --- a/packages/blockly/core/blockly.ts +++ b/packages/blockly/core/blockly.ts @@ -118,6 +118,8 @@ import * as icons from './icons.js'; import {inject} from './inject.js'; import * as inputs from './inputs.js'; import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js'; +import {MoveIndicator} from './keyboard_nav/move_indicator.js'; import {LabelFlyoutInflater} from './label_flyout_inflater.js'; import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js'; import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; @@ -125,7 +127,10 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; import {Input} from './inputs/input.js'; import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; -import {IBoundedElement} from './interfaces/i_bounded_element.js'; +import { + IBoundedElement, + isBoundedElement, +} from './interfaces/i_bounded_element.js'; import {IBubble} from './interfaces/i_bubble.js'; import {ICollapsibleToolboxItem} from './interfaces/i_collapsible_toolbox_item.js'; import {IComponent} from './interfaces/i_component.js'; @@ -137,6 +142,7 @@ import {IDeletable, isDeletable} from './interfaces/i_deletable.js'; import {IDeleteArea} from './interfaces/i_delete_area.js'; import {IDragTarget} from './interfaces/i_drag_target.js'; import { + DragDisposition, IDragStrategy, IDraggable, isDraggable, @@ -500,6 +506,8 @@ export { BlockFlyoutInflater, ButtonFlyoutInflater, CodeGenerator, + Direction, + DragDisposition, Field, FieldCheckbox, FieldCheckboxConfig, @@ -584,6 +592,7 @@ export { ImageProperties, Input, InsertionMarkerPreviewer, + KeyboardMover, KeyboardNavigationController, LabelFlyoutInflater, LayerManager, @@ -595,6 +604,7 @@ export { MenuItem, MenuOption, MetricsManager, + MoveIndicator, Msg, Names, Options, @@ -626,6 +636,7 @@ export { icons, inject, inputs, + isBoundedElement, isCopyable, isDeletable, isDraggable, diff --git a/packages/blockly/core/bubbles/bubble.ts b/packages/blockly/core/bubbles/bubble.ts index 742d300ad..569a6c10c 100644 --- a/packages/blockly/core/bubbles/bubble.ts +++ b/packages/blockly/core/bubbles/bubble.ts @@ -8,6 +8,7 @@ import * as browserEvents from '../browser_events.js'; import * as common from '../common.js'; import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js'; import {getFocusManager} from '../focus_manager.js'; +import {IBoundedElement} from '../interfaces/i_bounded_element.js'; import {IBubble} from '../interfaces/i_bubble.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; @@ -29,7 +30,9 @@ import {WorkspaceSvg} from '../workspace_svg.js'; * bubble, where it has a "tail" that points to the block, and a "head" that * displays arbitrary svg elements. */ -export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { +export abstract class Bubble + implements IBubble, ISelectable, IFocusableNode, IBoundedElement +{ /** The width of the border around the bubble. */ static readonly BORDER_WIDTH = 6; @@ -274,6 +277,18 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`); } + /** + * Moves the bubble by the given amounts in the x and y directions. + * + * @param dx The distance to move along the x axis. + * @param dy The distance to move along the y axis. + * @param _reason A description of why this move is happening. + */ + moveBy(dx: number, dy: number, _reason?: string[]) { + const origin = this.getRelativeToSurfaceXY(); + this.moveTo(origin.x + dx, origin.y + dy); + } + /** * Positions the bubble "optimally" so that the most of it is visible and * it does not overlap the rect (if provided). @@ -617,6 +632,21 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { ); } + /** + * Returns the bounds of this bubble. + * + * @returns A bounding box for this bubble. + */ + getBoundingRectangle(): Rect { + const origin = this.getRelativeToSurfaceXY(); + return new Rect( + origin.y, + origin.y + this.size.height, + origin.x, + origin.x + this.size.width, + ); + } + /** @internal */ getSvgRoot(): SVGElement { return this.svgRoot; @@ -664,8 +694,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { } /** Starts a drag on the bubble. */ - startDrag(): void { - this.dragStrategy.startDrag(); + startDrag() { + return this.dragStrategy.startDrag(); } /** Drags the bubble to the given location. */ diff --git a/packages/blockly/core/bubbles/mini_workspace_bubble.ts b/packages/blockly/core/bubbles/mini_workspace_bubble.ts index 194cb41f3..00a50dc30 100644 --- a/packages/blockly/core/bubbles/mini_workspace_bubble.ts +++ b/packages/blockly/core/bubbles/mini_workspace_bubble.ts @@ -6,6 +6,7 @@ import type {BlocklyOptions} from '../blockly_options.js'; import {Abstract as AbstractEvent} from '../events/events_abstract.js'; +import {KeyboardMover} from '../keyboard_nav/keyboard_mover.js'; import {Options} from '../options.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; @@ -153,11 +154,10 @@ export class MiniWorkspaceBubble extends Bubble { * are dealt with by resizing the workspace to show them. */ private bumpBlocksIntoBounds() { - if ( - this.miniWorkspace.isDragging() && - !this.miniWorkspace.keyboardMoveInProgress - ) + // Only bump for mouse-driven drags. + if (this.miniWorkspace.isDragging() && !KeyboardMover.mover.isMoving()) { return; + } const MARGIN = 20; @@ -189,15 +189,13 @@ export class MiniWorkspaceBubble extends Bubble { * mini workspace. */ private updateBubbleSize() { - if ( - this.miniWorkspace.isDragging() && - !this.miniWorkspace.keyboardMoveInProgress - ) + if (this.miniWorkspace.isDragging() && !KeyboardMover.mover.isMoving()) { return; + } // Disable autolayout if a keyboard move is in progress to prevent the // mutator bubble from jumping around. - this.autoLayout &&= !this.miniWorkspace.keyboardMoveInProgress; + this.autoLayout &&= !KeyboardMover.mover.isMoving(); const currSize = this.getSize(); const newSize = this.calculateWorkspaceSize(); diff --git a/packages/blockly/core/comments/rendered_workspace_comment.ts b/packages/blockly/core/comments/rendered_workspace_comment.ts index 59e462c95..b422d252d 100644 --- a/packages/blockly/core/comments/rendered_workspace_comment.ts +++ b/packages/blockly/core/comments/rendered_workspace_comment.ts @@ -239,8 +239,8 @@ export class RenderedWorkspaceComment } /** Starts a drag on the comment. */ - startDrag(): void { - this.dragStrategy.startDrag(); + startDrag() { + return this.dragStrategy.startDrag(); } /** Drags the comment to the given location. */ diff --git a/packages/blockly/core/comments/workspace_comment.ts b/packages/blockly/core/comments/workspace_comment.ts index b5dc3023c..19de23512 100644 --- a/packages/blockly/core/comments/workspace_comment.ts +++ b/packages/blockly/core/comments/workspace_comment.ts @@ -221,7 +221,7 @@ export class WorkspaceComment { /** Returns the position of the comment in workspace coordinates. */ getRelativeToSurfaceXY(): Coordinate { - return this.location; + return this.location.clone(); } /** Disposes of this comment. */ diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 0fb6d531e..d63eb2cab 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -6,7 +6,7 @@ import type {Block} from '../block.js'; import * as blockAnimation from '../block_animations.js'; -import {BlockSvg} from '../block_svg.js'; +import type {BlockSvg} from '../block_svg.js'; import * as bumpObjects from '../bump_objects.js'; import {config} from '../config.js'; import {Connection} from '../connection.js'; @@ -14,17 +14,20 @@ import {ConnectionType} from '../connection_type.js'; import type {BlockMove} from '../events/events_block_move.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; +import {showUnconstrainedMoveHint} from '../hints.js'; import type {IBubble} from '../interfaces/i_bubble.js'; -import {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js'; -import {IDragStrategy} from '../interfaces/i_draggable.js'; +import type {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js'; +import type {IDragStrategy} from '../interfaces/i_draggable.js'; +import {DragDisposition} from '../interfaces/i_draggable.js'; import {IHasBubble, hasBubble} from '../interfaces/i_has_bubble.js'; +import {Direction} from '../keyboard_nav/keyboard_mover.js'; import * as layers from '../layers.js'; import * as registry from '../registry.js'; import {finishQueuedRenders} from '../render_management.js'; -import {RenderedConnection} from '../rendered_connection.js'; +import type {RenderedConnection} from '../rendered_connection.js'; import {Coordinate} from '../utils.js'; import * as dom from '../utils/dom.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; /** Represents a nearby valid connection. */ interface ConnectionCandidate { @@ -38,6 +41,16 @@ interface ConnectionCandidate { distance: number; } +/** + * Represents a block movement paradigm; constrained moves only to valid + * connections, while unconstrained allows free movement to anywhere on the + * workspace. + */ +enum MoveMode { + CONSTRAINED = 1, + UNCONSTRAINED = 2, +} + export class BlockDragStrategy implements IDragStrategy { private workspace: WorkspaceSvg; @@ -58,25 +71,26 @@ export class BlockDragStrategy implements IDragStrategy { private dragging = false; - /** - * If this is a shadow block, the offset between this block and the parent - * block, to add to the drag location. In workspace units. - */ - private dragOffset = new Coordinate(0, 0); + /** Where a constrained movement should start when traversing the tree. */ + private searchNode: RenderedConnection | null = null; + + /** List of all connections available on the workspace. */ + private allConnections: RenderedConnection[] = []; + + /** The current movement mode. */ + private moveMode = MoveMode.UNCONSTRAINED; /** Used to persist an event group when snapping is done async. */ private originalEventGroup = ''; + protected readonly BLOCK_CONNECTION_OFFSET = 10; + constructor(private block: BlockSvg) { this.workspace = block.workspace; } /** Returns true if the block is currently movable. False otherwise. */ isMovable(): boolean { - if (this.block.isShadow()) { - return this.block.getParent()?.isMovable() ?? false; - } - return ( this.block.isOwnMovable() && !this.block.isDeadOrDying() && @@ -91,12 +105,7 @@ export class BlockDragStrategy implements IDragStrategy { * Handles any setup for starting the drag, including disconnecting the block * from any parent blocks. */ - startDrag(e?: PointerEvent): void { - if (this.block.isShadow()) { - this.startDraggingShadow(e); - return; - } - + startDrag(e?: PointerEvent | KeyboardEvent) { this.dragging = true; this.fireDragStartEvent(); @@ -125,6 +134,40 @@ export class BlockDragStrategy implements IDragStrategy { this.getVisibleBubbles(this.block).forEach((bubble) => { this.workspace.getLayerManager()?.moveToDragLayer(bubble, false); }); + + // For keyboard-driven moves, cache a list of valid connection points for + // use in constrained moved mode. + if (e instanceof KeyboardEvent) { + for (const topBlock of this.block.workspace.getTopBlocks(true)) { + this.allConnections.push(...this.getAllConnections(topBlock)); + } + + // Scooch the block to be offset from the connection preview indicator. + this.block.moveDuringDrag(this.startLoc); + this.connectionCandidate = this.createInitialCandidate(); + const neighbour = this.updateConnectionPreview( + this.block, + new Coordinate(0, 0), + ); + if (neighbour) { + let offset: Coordinate; + if (neighbour.type === ConnectionType.PREVIOUS_STATEMENT) { + const origin = this.block.getRelativeToSurfaceXY(); + offset = new Coordinate( + origin.x + this.BLOCK_CONNECTION_OFFSET, + origin.y - this.BLOCK_CONNECTION_OFFSET, + ); + } else { + offset = new Coordinate( + neighbour.x + this.BLOCK_CONNECTION_OFFSET, + neighbour.y + this.BLOCK_CONNECTION_OFFSET, + ); + } + this.block.moveDuringDrag(offset); + } + } + + return this.block; } /** @@ -159,24 +202,10 @@ export class BlockDragStrategy implements IDragStrategy { * @returns True if just the initial block should be dragged out, false * if all following blocks should also be dragged. */ - protected shouldHealStack(e: PointerEvent | undefined) { - return !!e && (e.altKey || e.ctrlKey || e.metaKey); - } - - /** Starts a drag on a shadow, recording the drag offset. */ - private startDraggingShadow(e?: PointerEvent) { - const parent = this.block.getParent(); - if (!parent) { - throw new Error( - 'Tried to drag a shadow block with no parent. ' + - 'Shadow blocks should always have parents.', - ); - } - this.dragOffset = Coordinate.difference( - parent.getRelativeToSurfaceXY(), - this.block.getRelativeToSurfaceXY(), - ); - parent.startDrag(e); + protected shouldHealStack(e: PointerEvent | KeyboardEvent | undefined) { + return e instanceof PointerEvent + ? e.ctrlKey || e.metaKey + : !!this.block.previousConnection; } /** @@ -246,25 +275,62 @@ export class BlockDragStrategy implements IDragStrategy { } /** Moves the block and updates any connection previews. */ - drag(newLoc: Coordinate): void { - if (this.block.isShadow()) { - this.block.getParent()?.drag(Coordinate.sum(newLoc, this.dragOffset)); - return; - } + drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void { + this.moveMode = + e instanceof KeyboardEvent && !(e.ctrlKey || e.metaKey) + ? MoveMode.CONSTRAINED + : MoveMode.UNCONSTRAINED; - this.block.moveDuringDrag(newLoc); + if (this.moveMode === MoveMode.UNCONSTRAINED) { + this.block.moveDuringDrag(newLoc); + } this.updateConnectionPreview( this.block, Coordinate.difference(newLoc, this.startLoc!), ); + + // Handle the case where the drag has reached a possible connection. + if (this.connectionCandidate) { + const neighbour = this.connectionCandidate.neighbour; + // The next constrained move will resume the search from the current + // candidate location. + this.searchNode = neighbour; + if (this.moveMode === MoveMode.CONSTRAINED) { + // Position the moving block down and slightly to the right of the + // target connection. + this.block.moveDuringDrag( + new Coordinate( + neighbour.x + this.BLOCK_CONNECTION_OFFSET, + neighbour.y + this.BLOCK_CONNECTION_OFFSET, + ), + ); + } + } else { + // No connection was available or adequately close to the dragged block; + // clear out the search node since we have nowhere to search from, and + // suggest using unconstrained mode to arbitrarily position the block if + // we're in keyboard-driven constrained mode. + this.searchNode = null; + + if (this.moveMode === MoveMode.CONSTRAINED) { + showUnconstrainedMoveHint(this.workspace, true); + } + } } /** + * Renders the connection preview indicator. + * * @param draggingBlock The block being dragged. * @param delta How far the pointer has moved from the position * at the start of the drag, in workspace units. + * @returns The neighbouring connection to which the connection preview will + * be attached. */ - private updateConnectionPreview(draggingBlock: BlockSvg, delta: Coordinate) { + private updateConnectionPreview( + draggingBlock: BlockSvg, + delta: Coordinate, + ): RenderedConnection | undefined { const currCandidate = this.connectionCandidate; const newCandidate = this.getConnectionCandidate(draggingBlock, delta); if (!newCandidate) { @@ -299,9 +365,10 @@ export class BlockDragStrategy implements IDragStrategy { neighbour, neighbour.targetBlock()!, ); - return; + } else { + this.connectionPreviewer?.previewConnection(local, neighbour); } - this.connectionPreviewer?.previewConnection(local, neighbour); + return neighbour; } /** @@ -333,6 +400,9 @@ export class BlockDragStrategy implements IDragStrategy { delta: Coordinate, newCandidate: ConnectionCandidate, ): boolean { + // New connection is always better during a constrained move. + if (this.moveMode === MoveMode.CONSTRAINED) return false; + const {local: currLocal, neighbour: currNeighbour} = currCandiate; const localPos = new Coordinate(currLocal.x, currLocal.y); const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y); @@ -356,9 +426,26 @@ export class BlockDragStrategy implements IDragStrategy { delta: Coordinate, ): ConnectionCandidate | null { const localConns = this.getLocalConnections(draggingBlock); - let radius = this.getSearchRadius(); let candidate = null; + if (this.moveMode === MoveMode.CONSTRAINED) { + const direction = this.getDirectionToNewLocation( + Coordinate.sum(this.startLoc!, delta), + ); + candidate = this.findTraversalCandidate( + draggingBlock, + localConns, + direction, + ); + if (candidate) { + return candidate; + } + + delta = new Coordinate(0, 0); + } + + let radius = this.getSearchRadius(); + for (const conn of localConns) { const {connection: neighbour, radius: rad} = conn.closest(radius, delta); if (neighbour) { @@ -378,6 +465,8 @@ export class BlockDragStrategy implements IDragStrategy { * Get the radius to use when searching for a nearby valid connection. */ protected getSearchRadius() { + if (this.moveMode === MoveMode.CONSTRAINED) return Infinity; + return this.connectionCandidate ? config.connectingSnapRadius : config.snapRadius; @@ -402,11 +491,14 @@ export class BlockDragStrategy implements IDragStrategy { * Cleans up any state at the end of the drag. Applies any pending * connections. */ - endDrag(e?: PointerEvent): void { - if (this.block.isShadow()) { - this.block.getParent()?.endDrag(e); - return; + endDrag( + _e: PointerEvent | KeyboardEvent | undefined, + disposition: DragDisposition, + ): void { + if (disposition === DragDisposition.DELETE) { + blockAnimation.disposeUiEffect(this.block); } + this.originalEventGroup = eventUtils.getGroup(); this.fireDragEndEvent(); @@ -440,6 +532,8 @@ export class BlockDragStrategy implements IDragStrategy { } else { this.block.queueRender().then(() => this.disposeStep()); } + + this.allConnections = []; } /** Disposes of any state at the end of the drag. */ @@ -477,11 +571,6 @@ export class BlockDragStrategy implements IDragStrategy { * including reconnecting connections. */ revertDrag(): void { - if (this.block.isShadow()) { - this.block.getParent()?.revertDrag(); - return; - } - this.connectionPreviewer?.hidePreview(); this.connectionCandidate = null; @@ -520,4 +609,171 @@ export class BlockDragStrategy implements IDragStrategy { this.block.setDragging(false); this.dragging = false; } + + /** + * Get the nearest valid candidate connection in traversal order. + * + * @param draggingBlock The root block being dragged. + * @param localConns The list of connections on the dragging block(s) that are + * available to connect to. + * @param direction The cardinal direction in which the block is being moved. + * @returns A candidate connection and radius, or null if none was found. + */ + findTraversalCandidate( + draggingBlock: BlockSvg, + localConns: RenderedConnection[], + direction: Direction, + ): ConnectionCandidate | null { + const connectionChecker = draggingBlock.workspace.connectionChecker; + let candidateConnection: ConnectionCandidate | null = null; + let potential: RenderedConnection | null = this.searchNode; + + while (potential && !candidateConnection) { + const potentialIndex = this.allConnections.indexOf(potential); + if (direction === Direction.UP || direction === Direction.LEFT) { + potential = + this.allConnections[potentialIndex - 1] ?? + this.allConnections[this.allConnections.length - 1]; + } else if ( + direction === Direction.DOWN || + direction === Direction.RIGHT + ) { + potential = + this.allConnections[potentialIndex + 1] ?? this.allConnections[0]; + } + + localConns.forEach((conn: RenderedConnection) => { + if ( + potential && + connectionChecker.canConnect(conn, potential, true, Infinity) && + !potential.targetBlock()?.isInsertionMarker() + ) { + candidateConnection = { + local: conn, + neighbour: potential, + distance: 0, + }; + } + }); + if (potential == this.searchNode) break; + } + return candidateConnection; + } + + /** + * Create a candidate representing where the block was previously connected. + * Used to render the block position after picking up the block but before + * moving during a drag. + * + * @returns A connection candidate representing where the block was at the + * start of the drag. + */ + private createInitialCandidate(): ConnectionCandidate | null { + this.searchNode = this.startParentConn ?? this.startChildConn; + + switch (this.searchNode?.type) { + case ConnectionType.INPUT_VALUE: { + if (this.block.outputConnection) { + return { + neighbour: this.searchNode, + local: this.block.outputConnection, + distance: 0, + }; + } + break; + } + case ConnectionType.NEXT_STATEMENT: { + if (this.block.previousConnection) { + return { + neighbour: this.searchNode, + local: this.block.previousConnection, + distance: 0, + }; + } + break; + } + case ConnectionType.PREVIOUS_STATEMENT: { + if (this.block.nextConnection) { + return { + neighbour: this.searchNode, + local: this.block.nextConnection, + distance: 0, + }; + } + break; + } + } + + return null; + } + + /** + * Returns the cardinal direction that the block being dragged would have to + * move in to reach the given location. + * The given coordinate should differ from the current location on only one + * axis. + * + * @param newLocation The intended destination for the block. + * @returns The direction the block would need to travel to reach the new + * location. + */ + private getDirectionToNewLocation(newLocation: Coordinate): Direction { + const actualPosition = this.block.getRelativeToSurfaceXY(); + const delta = Coordinate.difference(newLocation, actualPosition); + const {x, y} = delta; + if (x) { + if (x < 0) { + return Direction.LEFT; + } else if (x > 0) { + return Direction.RIGHT; + } + } else if (y) { + if (y < 0) { + return Direction.UP; + } else if (y > 0) { + return Direction.DOWN; + } + } + return Direction.NONE; + } + + /** + * Returns all navigable connections on the given block and its children. + * Omits connections on shadow blocks, collapsed blocks, or those that are + * associated with a hidden input. + * + * @param block The block to use as a starting point for retrieving + * connections. + * @returns All connections on the block and its children. + */ + private getAllConnections(block: BlockSvg): RenderedConnection[] { + if (block.isShadow()) return []; + + const connections = []; + + if (block.outputConnection) connections.push(block.outputConnection); + if (block.previousConnection) connections.push(block.previousConnection); + + if (!block.isCollapsed()) { + for (const input of block.inputList) { + if (input.connection && input.isVisible()) { + connections.push(input.connection); + const target = input.connection.targetBlock() as BlockSvg; + if (target) { + connections.push(...this.getAllConnections(target)); + } + } + } + } + if (block.nextConnection) { + connections.push(block.nextConnection); + + const target = block.nextConnection.targetBlock() as BlockSvg; + if (target) { + connections.push(...this.getAllConnections(target)); + } + } + + return connections as RenderedConnection[]; + } } diff --git a/packages/blockly/core/dragging/bubble_drag_strategy.ts b/packages/blockly/core/dragging/bubble_drag_strategy.ts index 8a5a67839..87a2d81e7 100644 --- a/packages/blockly/core/dragging/bubble_drag_strategy.ts +++ b/packages/blockly/core/dragging/bubble_drag_strategy.ts @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {IBubble, WorkspaceSvg} from '../blockly.js'; -import {IDragStrategy} from '../interfaces/i_draggable.js'; +import type {IBubble} from '../interfaces/i_bubble.js'; +import type {IDragStrategy} from '../interfaces/i_draggable.js'; import * as layers from '../layers.js'; -import {Coordinate} from '../utils.js'; +import type {Coordinate} from '../utils.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; export class BubbleDragStrategy implements IDragStrategy { private startLoc: Coordinate | null = null; @@ -21,13 +22,15 @@ export class BubbleDragStrategy implements IDragStrategy { return true; } - startDrag(): void { + startDrag() { this.startLoc = this.bubble.getRelativeToSurfaceXY(); this.workspace.setResizesEnabled(false); this.workspace.getLayerManager()?.moveToDragLayer(this.bubble); if (this.bubble.setDragging) { this.bubble.setDragging(true); } + + return this.bubble; } drag(newLoc: Coordinate): void { diff --git a/packages/blockly/core/dragging/comment_drag_strategy.ts b/packages/blockly/core/dragging/comment_drag_strategy.ts index b7974d8b4..45c1690b0 100644 --- a/packages/blockly/core/dragging/comment_drag_strategy.ts +++ b/packages/blockly/core/dragging/comment_drag_strategy.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {RenderedWorkspaceComment} from '../comments.js'; -import {CommentMove} from '../events/events_comment_move.js'; +import type {RenderedWorkspaceComment} from '../comments.js'; +import type {CommentMove} from '../events/events_comment_move.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; -import {IDragStrategy} from '../interfaces/i_draggable.js'; +import type {IDragStrategy} from '../interfaces/i_draggable.js'; import * as layers from '../layers.js'; -import {Coordinate} from '../utils.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import type {Coordinate} from '../utils.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; export class CommentDragStrategy implements IDragStrategy { private startLoc: Coordinate | null = null; @@ -30,12 +30,13 @@ export class CommentDragStrategy implements IDragStrategy { ); } - startDrag(): void { + startDrag() { this.fireDragStartEvent(); this.startLoc = this.comment.getRelativeToSurfaceXY(); this.workspace.setResizesEnabled(false); this.workspace.getLayerManager()?.moveToDragLayer(this.comment); this.comment.setDragging(true); + return this.comment; } drag(newLoc: Coordinate): void { diff --git a/packages/blockly/core/dragging/dragger.ts b/packages/blockly/core/dragging/dragger.ts index 02e9e2bfb..f0bde64ce 100644 --- a/packages/blockly/core/dragging/dragger.ts +++ b/packages/blockly/core/dragging/dragger.ts @@ -4,20 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as blockAnimations from '../block_animations.js'; -import {BlockSvg} from '../block_svg.js'; import {ComponentManager} from '../component_manager.js'; import * as eventUtils from '../events/utils.js'; import {getFocusManager} from '../focus_manager.js'; -import {IDeletable, isDeletable} from '../interfaces/i_deletable.js'; -import {IDeleteArea} from '../interfaces/i_delete_area.js'; -import {IDragTarget} from '../interfaces/i_drag_target.js'; -import {IDraggable} from '../interfaces/i_draggable.js'; -import {IDragger} from '../interfaces/i_dragger.js'; +import type {IDeletable} from '../interfaces/i_deletable.js'; +import {isDeletable} from '../interfaces/i_deletable.js'; +import type {IDeleteArea} from '../interfaces/i_delete_area.js'; +import type {IDragTarget} from '../interfaces/i_drag_target.js'; +import {DragDisposition, type IDraggable} from '../interfaces/i_draggable.js'; +import type {IDragger} from '../interfaces/i_dragger.js'; import {isFocusableNode} from '../interfaces/i_focusable_node.js'; import * as registry from '../registry.js'; import {Coordinate} from '../utils/coordinate.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; export class Dragger implements IDragger { protected startLoc: Coordinate; @@ -32,11 +31,14 @@ export class Dragger implements IDragger { } /** Handles any drag startup. */ - onDragStart(e: PointerEvent) { + onDragStart(e?: PointerEvent | KeyboardEvent) { if (!eventUtils.getGroup()) { eventUtils.setGroup(true); } - this.draggable.startDrag(e); + this.draggable = this.draggable.startDrag(e); + this.startLoc = this.draggable.getRelativeToSurfaceXY(); + + return this.draggable; } /** @@ -45,27 +47,30 @@ export class Dragger implements IDragger { * @param totalDelta The total amount in pixel coordinates the mouse has moved * since the start of the drag. */ - onDrag(e: PointerEvent, totalDelta: Coordinate) { + onDrag(e: PointerEvent | KeyboardEvent | undefined, totalDelta: Coordinate) { this.moveDraggable(e, totalDelta); - const root = this.getRoot(this.draggable); // Must check `wouldDelete` before calling other hooks on drag targets // since we have documented that we would do so. - if (isDeletable(root)) { - root.setDeleteStyle(this.wouldDeleteDraggable(e, root)); + if (isDeletable(this.draggable)) { + this.draggable.setDeleteStyle( + this.wouldDeleteDraggable( + this.draggable.getRelativeToSurfaceXY(), + this.draggable, + ), + ); } - this.updateDragTarget(e); + this.updateDragTarget(this.draggable.getRelativeToSurfaceXY()); } /** Updates the drag target under the pointer (if there is one). */ - protected updateDragTarget(e: PointerEvent) { - const newDragTarget = this.workspace.getDragTarget(e); - const root = this.getRoot(this.draggable); + protected updateDragTarget(coordinate: Coordinate) { + const newDragTarget = this.workspace.getDragTarget(coordinate); if (this.dragTarget !== newDragTarget) { - this.dragTarget?.onDragExit(root); - newDragTarget?.onDragEnter(root); + this.dragTarget?.onDragExit(this.draggable); + newDragTarget?.onDragEnter(this.draggable); } - newDragTarget?.onDragOver(root); + newDragTarget?.onDragOver(this.draggable); this.dragTarget = newDragTarget; } @@ -73,7 +78,10 @@ export class Dragger implements IDragger { * Calculates the correct workspace coordinate for the movable and tells * the draggable to go to that location. */ - private moveDraggable(e: PointerEvent, totalDelta: Coordinate) { + private moveDraggable( + e: PointerEvent | KeyboardEvent | undefined, + totalDelta: Coordinate, + ) { const delta = this.pixelsToWorkspaceUnits(totalDelta); const newLoc = Coordinate.sum(this.startLoc, delta); this.draggable.drag(newLoc, e); @@ -84,10 +92,10 @@ export class Dragger implements IDragger { * at the current location. */ protected wouldDeleteDraggable( - e: PointerEvent, + coordinate: Coordinate, rootDraggable: IDraggable & IDeletable, ) { - const dragTarget = this.workspace.getDragTarget(e); + const dragTarget = this.workspace.getDragTarget(coordinate); if (!dragTarget) return false; const componentManager = this.workspace.getComponentManager(); @@ -101,34 +109,46 @@ export class Dragger implements IDragger { } /** Handles any drag cleanup. */ - onDragEnd(e: PointerEvent) { + onDragEnd(e?: PointerEvent | KeyboardEvent) { const origGroup = eventUtils.getGroup(); - const dragTarget = this.workspace.getDragTarget(e); - const root = this.getRoot(this.draggable); + const dragTarget = this.workspace.getDragTarget( + this.draggable.getRelativeToSurfaceXY(), + ); if (dragTarget) { - this.dragTarget?.onDrop(root); + this.dragTarget?.onDrop(this.draggable); } - if (this.shouldReturnToStart(e, root)) { + let reverted = false; + if ( + this.shouldReturnToStart( + this.draggable.getRelativeToSurfaceXY(), + this.draggable, + ) + ) { + reverted = true; this.draggable.revertDrag(); } - const wouldDelete = isDeletable(root) && this.wouldDeleteDraggable(e, root); + const wouldDelete = + isDeletable(this.draggable) && + this.wouldDeleteDraggable( + this.draggable.getRelativeToSurfaceXY(), + this.draggable, + ); - // TODO(#8148): use a generalized API instead of an instanceof check. - if (wouldDelete && this.draggable instanceof BlockSvg) { - blockAnimations.disposeUiEffect(this.draggable.getRootBlock()); - } - - this.draggable.endDrag(e); - - if (wouldDelete && isDeletable(root)) { + if (wouldDelete && isDeletable(this.draggable)) { + this.draggable.endDrag(e, DragDisposition.DELETE); // We want to make sure the delete gets grouped with any possible move // event. In core Blockly this shouldn't happen, but due to a change // in behavior older custom draggables might still clear the group. eventUtils.setGroup(origGroup); - root.dispose(); + this.draggable.dispose(); + } else { + this.draggable.endDrag( + e, + reverted ? DragDisposition.REVERT : DragDisposition.COMMIT, + ); } eventUtils.setGroup(false); @@ -139,18 +159,23 @@ export class Dragger implements IDragger { } } - // We need to special case blocks for now so that we look at the root block - // instead of the one actually being dragged in most cases. - private getRoot(draggable: IDraggable): IDraggable { - return draggable instanceof BlockSvg ? draggable.getRootBlock() : draggable; + /** Handles a drag being reverted. */ + onDragRevert() { + this.draggable.revertDrag(); + if (isFocusableNode(this.draggable)) { + getFocusManager().focusNode(this.draggable); + } } /** * Returns true if we should return the draggable to its original location * at the end of the drag. */ - protected shouldReturnToStart(e: PointerEvent, rootDraggable: IDraggable) { - const dragTarget = this.workspace.getDragTarget(e); + protected shouldReturnToStart( + coordinate: Coordinate, + rootDraggable: IDraggable, + ) { + const dragTarget = this.workspace.getDragTarget(coordinate); if (!dragTarget) return false; return dragTarget.shouldPreventMove(rootDraggable); } diff --git a/packages/blockly/core/hints.ts b/packages/blockly/core/hints.ts new file mode 100644 index 000000000..f01d9287c --- /dev/null +++ b/packages/blockly/core/hints.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Msg} from './msg.js'; +import {Toast} from './toast.js'; +import * as userAgent from './utils/useragent.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +const unconstrainedMoveHintId = 'unconstrainedMoveHint'; +const constrainedMoveHintId = 'constrainedMoveHint'; + +/** + * Nudge the user to use unconstrained movement. + * + * @param workspace Workspace. + * @param force Set to show it even if previously shown. + */ +export function showUnconstrainedMoveHint( + workspace: WorkspaceSvg, + force = false, +) { + const modifier = + userAgent.MAC || userAgent.IPAD || userAgent.IPHONE + ? Msg['COMMAND_KEY'] + : Msg['CONTROL_KEY']; + const message = Msg['KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT'] + .replace('%1', modifier) + .replace('%2', Msg['ENTER_KEY']); + Toast.show(workspace, { + message, + id: unconstrainedMoveHintId, + oncePerSession: !force, + }); +} + +/** + * Nudge the user to move a block that's in move mode. + * + * @param workspace Workspace. + */ +export function showConstrainedMovementHint(workspace: WorkspaceSvg) { + const message = Msg['KEYBOARD_NAV_CONSTRAINED_MOVE_HINT'].replace( + '%1', + Msg['ENTER_KEY'], + ); + Toast.show(workspace, { + message, + id: constrainedMoveHintId, + oncePerSession: true, + }); +} + +/** + * Clear active move-related hints, if any. + * + * @param workspace The workspace. + */ +export function clearMoveHints(workspace: WorkspaceSvg) { + Toast.hide(workspace, constrainedMoveHintId); + Toast.hide(workspace, unconstrainedMoveHintId); +} diff --git a/packages/blockly/core/interfaces/i_bounded_element.ts b/packages/blockly/core/interfaces/i_bounded_element.ts index aac26855b..a7d2ef0e5 100644 --- a/packages/blockly/core/interfaces/i_bounded_element.ts +++ b/packages/blockly/core/interfaces/i_bounded_element.ts @@ -29,3 +29,16 @@ export interface IBoundedElement { */ moveBy(dx: number, dy: number, reason?: string[]): void; } + +/** + * Returns whether or not the given object conforms to IBoundedElement. + * + * @param object The object to test for conformance. + * @returns True if the object conforms to IBoundedElement, otherwise false. + */ +export function isBoundedElement(object: any): object is IBoundedElement { + return ( + typeof (object as IBoundedElement).getBoundingRectangle === 'function' && + typeof (object as IBoundedElement).moveBy === 'function' + ); +} diff --git a/packages/blockly/core/interfaces/i_bubble.ts b/packages/blockly/core/interfaces/i_bubble.ts index 553f86e9e..e4dc40608 100644 --- a/packages/blockly/core/interfaces/i_bubble.ts +++ b/packages/blockly/core/interfaces/i_bubble.ts @@ -7,14 +7,19 @@ // Former goog.module ID: Blockly.IBubble import type {Coordinate} from '../utils/coordinate.js'; +import {IBoundedElement} from './i_bounded_element.js'; import type {IContextMenu} from './i_contextmenu.js'; import type {IDraggable} from './i_draggable.js'; -import {IFocusableNode} from './i_focusable_node.js'; +import {ISelectable} from './i_selectable.js'; /** * A bubble interface. */ -export interface IBubble extends IDraggable, IContextMenu, IFocusableNode { +export interface IBubble + extends IDraggable, + IContextMenu, + ISelectable, + IBoundedElement { /** * Return the coordinates of the top-left corner of this bubble's body * relative to the drawing surface's origin (0,0), in workspace units. diff --git a/packages/blockly/core/interfaces/i_draggable.ts b/packages/blockly/core/interfaces/i_draggable.ts index 913038116..3b8f43735 100644 --- a/packages/blockly/core/interfaces/i_draggable.ts +++ b/packages/blockly/core/interfaces/i_draggable.ts @@ -4,15 +4,25 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {Coordinate} from '../utils/coordinate'; +import type {Coordinate} from '../utils/coordinate.js'; +import type {IBoundedElement} from './i_bounded_element.js'; +import type {ISelectable} from './i_selectable.js'; + +export enum DragDisposition { + COMMIT = 1, + DELETE = 2, + REVERT = 3, +} /** * Represents an object that can be dragged. */ -export interface IDraggable extends IDragStrategy { +export interface IDraggable + extends IDragStrategy, + IBoundedElement, + ISelectable { /** - * Returns the current location of the draggable in workspace - * coordinates. + * Returns the current location of the draggable in workspace coordinates. * * @returns Coordinate of current location on workspace. */ @@ -27,11 +37,11 @@ export interface IDragStrategy { * Handles any drag startup (e.g moving elements to the front of the * workspace). * - * @param e PointerEvent that started the drag; can be used to - * check modifier keys, etc. May be missing when dragging is - * triggered programatically rather than by user. + * @param e Event that started the drag; can be used to check modifier keys, + * etc. May be missing when dragging is triggered programmatically rather + * than by user. */ - startDrag(e?: PointerEvent): void; + startDrag(e?: PointerEvent | KeyboardEvent): IDraggable; /** * Handles moving elements to the new location, and updating any @@ -39,21 +49,23 @@ export interface IDragStrategy { * * @param newLoc Workspace coordinate to which the draggable has * been dragged. - * @param e PointerEvent that continued the drag. Can be - * used to check modifier keys, etc. + * @param e Event that continued the drag. Can be used to check modifier + * keys, etc. */ - drag(newLoc: Coordinate, e?: PointerEvent): void; + drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void; /** - * Handles any drag cleanup, including e.g. connecting or deleting - * blocks. + * Handles any drag cleanup, including e.g. connecting or deleting blocks. * * @param newLoc Workspace coordinate at which the drag finished. - * been dragged. - * @param e PointerEvent that finished the drag. Can be - * used to check modifier keys, etc. + * @param e Event that finished the drag. Can be used to check modifier keys, + * etc. + * @param disposition The end result of the drag. */ - endDrag(e?: PointerEvent): void; + endDrag( + e: PointerEvent | KeyboardEvent | undefined, + disposition: DragDisposition, + ): void; /** Moves the draggable back to where it was at the start of the drag. */ revertDrag(): void; diff --git a/packages/blockly/core/interfaces/i_dragger.ts b/packages/blockly/core/interfaces/i_dragger.ts index 1e8ad0ab6..880917e9a 100644 --- a/packages/blockly/core/interfaces/i_dragger.ts +++ b/packages/blockly/core/interfaces/i_dragger.ts @@ -4,32 +4,51 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {Coordinate} from '../utils/coordinate'; +import type {Coordinate} from '../utils/coordinate'; +import type {IDraggable} from './i_draggable'; export interface IDragger { /** * Handles any drag startup. * - * @param e PointerEvent that started the drag. + * @param e Event that started the drag. */ - onDragStart(e: PointerEvent): void; + onDragStart(e?: PointerEvent | KeyboardEvent): IDraggable; /** * Handles dragging, including calculating where the element should * actually be moved to. * - * @param e PointerEvent that continued the drag. - * @param totalDelta The total distance, in pixels, that the mouse + * @param e Event that continued the drag. + * @param totalDelta The total distance, in pixels, that the draggable * has moved since the start of the drag. */ - onDrag(e: PointerEvent, totalDelta: Coordinate): void; + onDrag( + e: PointerEvent | KeyboardEvent | undefined, + totalDelta: Coordinate, + ): void; /** - * Handles any drag cleanup. + * Handles any drag cleanup when a drag finishes normally. * - * @param e PointerEvent that finished the drag. - * @param totalDelta The total distance, in pixels, that the mouse + * @param e Event that finished the drag. + * @param totalDelta The total distance, in pixels, that the draggable * has moved since the start of the drag. */ - onDragEnd(e: PointerEvent, totalDelta: Coordinate): void; + onDragEnd( + e: PointerEvent | KeyboardEvent | undefined, + totalDelta: Coordinate, + ): void; + + /** + * Handles any drag cleanup when a drag is reverted. + * + * @param e Event that finished the drag. + * @param totalDelta The total distance, in pixels, that the draggable + * has moved since the start of the drag. + */ + onDragRevert( + e: PointerEvent | KeyboardEvent | undefined, + totalDelta: Coordinate, + ): void; } diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts new file mode 100644 index 000000000..f3c9ecee0 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -0,0 +1,329 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IDraggable} from '../interfaces/i_draggable.js'; +import type {IDragger} from '../interfaces/i_dragger.js'; +import * as registry from '../registry.js'; +import {ShortcutRegistry} from '../shortcut_registry.js'; +import {Coordinate} from '../utils/coordinate.js'; +import {KeyCodes} from '../utils/keycodes.js'; +import {MoveIndicator} from './move_indicator.js'; + +/** + * Cardinal directions in which a move can proceed. + */ +export enum Direction { + NONE, + UP, + DOWN, + LEFT, + RIGHT, +} + +/** + * Identifier for a keyboard shortcut that commits the in-progress move. + */ +const COMMIT_MOVE_SHORTCUT = 'commitMove'; + +/** + * Class responsible for coordinating keyboard-driven moves with the workspace + * and dragging system. + */ +export class KeyboardMover { + /** + * Object responsible for dragging workspace elements in response to move + * commands. + */ + private dragger?: IDragger; + + /** + * The object that is currently being moved. + */ + private draggable?: IDraggable; + + /** + * Workspace coordinate that the current move started from. + */ + private startLocation?: Coordinate; + + /** + * The total distance, in workspace coordinates, that the element being moved + * has been moved since the movement process started. + */ + private totalDelta = new Coordinate(0, 0); + + /** + * The distance to move an item in workspace coordinates. + */ + private stepDistance = 20; + + /** + * Symbol attached to the item being moved to indicate it is in move mode. + */ + private moveIndicator?: MoveIndicator; + + private allowedShortcuts: string[] = []; + + // Set up a blur listener to end the move if the user clicks away + private readonly blurListener = () => { + this.abortMove(); + }; + + static mover = new KeyboardMover(); + + // Constructor is private to keep this class a singleton. + private constructor() {} + + /** + * Returns true iff the given draggable is allowed to be moved. + * + * @param draggable The draggable element to try to move. + * @returns True iff movement is allowed. + */ + canMove(draggable: IDraggable) { + return !draggable.workspace.isReadOnly() && draggable.isMovable(); + } + + /** + * Returns true iff this Mover is currently moving an element. + * + * @returns True iff a workspace element is being moved. + */ + isMoving() { + return !!this.draggable; + } + + /** + * Start moving the currently-focused item on workspace, if possible. + * + * @param draggable The element to start moving. + * @param event The keyboard event that triggered this move. + * @returns True iff a move has successfully begun. + */ + startMove(draggable: IDraggable, event: KeyboardEvent) { + if (!this.canMove(draggable) || this.isMoving()) return false; + + const DraggerClass = registry.getClassFromOptions( + registry.Type.BLOCK_DRAGGER, + draggable.workspace.options, + true, + ); + if (!DraggerClass) throw new Error('no Dragger registered'); + this.dragger = new DraggerClass(draggable, draggable.workspace); + // Record that a move is in progress and start dragging. + this.draggable = this.dragger.onDragStart(event); + this.startLocation = this.draggable.getRelativeToSurfaceXY(); + + this.updateTotalDelta(); + + this.draggable + .getFocusableElement() + .addEventListener('blur', this.blurListener); + + // Register a keyboard shortcut under the key combos of all existing + // keyboard shortcuts that commits the move before allowing the real + // shortcut to proceed. This avoids all kinds of fun brokenness when + // deleting/copying/otherwise acting on a element in move mode. + const shortcutKeys = Object.values(ShortcutRegistry.registry.getRegistry()) + .flatMap((shortcut) => shortcut.keyCodes) + .filter((keyCode) => { + return ( + keyCode && + ![ + KeyCodes.RIGHT, + KeyCodes.LEFT, + KeyCodes.UP, + KeyCodes.DOWN, + KeyCodes.ENTER, + KeyCodes.ESC, + KeyCodes.M, + ].includes( + typeof keyCode === 'number' + ? keyCode + : parseInt(`${keyCode.split('+').pop()}`), + ) + ); + }) + // Convince TS there aren't undefined values. + .filter((keyCode): keyCode is string | number => !!keyCode); + + const commitMoveShortcut = { + name: COMMIT_MOVE_SHORTCUT, + preconditionFn: () => { + return this.isMoving(); + }, + callback: () => { + this.finishMove(); + return false; + }, + keyCodes: shortcutKeys, + allowCollision: true, + }; + + ShortcutRegistry.registry.register(commitMoveShortcut, true); + + this.scrollCurrentElementIntoView(); + this.moveIndicator = new MoveIndicator(this.draggable.workspace); + this.repositionMoveIndicator(); + + return true; + } + + /** + * Moves the current element in the given direction. + * + * @param direction The direction to move the currently-moving element. + * @param event The event that triggered this move, if any. + * @returns True iff this action applies and has been performed. + */ + move(direction: Direction, event?: KeyboardEvent | PointerEvent) { + switch (direction) { + case Direction.UP: + this.totalDelta.y -= this.stepDistance; + break; + case Direction.DOWN: + this.totalDelta.y += this.stepDistance; + break; + case Direction.LEFT: + this.totalDelta.x -= this.stepDistance; + break; + case Direction.RIGHT: + this.totalDelta.x += this.stepDistance; + break; + } + + this.dragger?.onDrag(event, this.totalPixelDelta()); + + this.updateTotalDelta(); + this.scrollCurrentElementIntoView(); + this.repositionMoveIndicator(); + + return true; + } + + /** + * Finish moving the item that is currently being moved. + * + * @param event The event that triggered the end of the move, if any. + * @returns True iff move successfully finished. + */ + finishMove(event?: KeyboardEvent | PointerEvent) { + this.preDragEndCleanup(); + + this.dragger?.onDragEnd(event, this.totalPixelDelta()); + + this.postDragEndCleanup(); + return true; + } + + /** + * Abort moving the currently-focused item on workspace. + * + * @param event The event that triggered the end of the move, if any. + * @returns True iff move successfully aborted. + */ + abortMove(event?: KeyboardEvent | PointerEvent) { + this.preDragEndCleanup(); + + this.dragger?.onDragRevert(event, this.totalPixelDelta()); + + this.postDragEndCleanup(); + return true; + } + + /** + * Sets the distance by which an object will be moved. + * + * @param stepDistance The distance in workspace coordinates that each move + * should move elements on the workspace by. + */ + setMoveDistance(stepDistance: number) { + this.stepDistance = stepDistance; + } + + /** + * Returns a list of the names of shortcuts that are allowed to be run while + * a keyboard-driven move is in progress. + */ + getAllowedShortcuts() { + return this.allowedShortcuts; + } + + /** + * Adds shortcuts with the given names to the list of shortcuts that are + * allowed to be run while a keyboard-driven move is in progress. + */ + setAllowedShortcuts(shortcutNames: string[]) { + this.allowedShortcuts = shortcutNames; + } + + /** + * Repositions the move indicator to the corner of the item being moved. + */ + private repositionMoveIndicator() { + const bounds = this.draggable?.getBoundingRectangle(); + if (!bounds) return; + + this.moveIndicator?.moveTo( + this.draggable?.workspace.RTL ? bounds.left : bounds.right, + bounds.top, + ); + } + + /** + * Common clean-up for finish/abort run before terminating the move. + */ + private preDragEndCleanup() { + ShortcutRegistry.registry.unregister(COMMIT_MOVE_SHORTCUT); + + // Remove the blur listener before ending the drag + this.draggable + ?.getFocusableElement() + .removeEventListener('blur', this.blurListener); + } + + /** + * Common clean-up for finish/abort run after terminating the move. + */ + private postDragEndCleanup() { + this.moveIndicator?.dispose(); + this.moveIndicator = undefined; + this.draggable = undefined; + this.dragger = undefined; + this.startLocation = undefined; + this.totalDelta = new Coordinate(0, 0); + } + + /** + * Returns the total distance current element has moved in pixels. + */ + private totalPixelDelta() { + const scale = this.draggable?.workspace.scale ?? 1; + return new Coordinate(this.totalDelta.x * scale, this.totalDelta.y * scale); + } + + /** + * Scrolls the current element into view. + */ + private scrollCurrentElementIntoView() { + if (!this.draggable) return; + const bounds = this.draggable.getBoundingRectangle(); + this.draggable.workspace.scrollBoundsIntoView(bounds); + } + + /** + * Recalculates the total movement delta from the starting location and the + * current position of the item being moved. + */ + private updateTotalDelta() { + if (!this.draggable || !this.startLocation) return; + + this.totalDelta = new Coordinate( + this.draggable.getRelativeToSurfaceXY().x - this.startLocation.x, + this.draggable.getRelativeToSurfaceXY().y - this.startLocation.y, + ); + } +} diff --git a/packages/blockly/core/keyboard_nav/move_indicator.ts b/packages/blockly/core/keyboard_nav/move_indicator.ts new file mode 100644 index 000000000..b1de2f768 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/move_indicator.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * Four-way arrow indicator attached to a workspace element to indicate that it + * is being moved. + */ +export class MoveIndicator { + /** + * Root SVG element for the indicator. + */ + svgRoot: SVGGElement; + + /** + * Creates a new move indicator. + * + * @param workspace The workspace the indicator should be displayed on. + */ + constructor(private workspace: WorkspaceSvg) { + this.svgRoot = dom.createSvgElement( + Svg.G, + {}, + workspace.getLayerManager()?.getDragLayer(), + ); + this.svgRoot.classList.add('blocklyMoveIndicator'); + const rtl = workspace.RTL; + dom.createSvgElement( + Svg.CIRCLE, + { + 'fill': 'white', + 'fill-opacity': '0.8', + 'stroke': 'grey', + 'stroke-width': '1', + 'r': 20, + 'cx': 20 * (rtl ? -1 : 1), + 'cy': 20, + }, + this.svgRoot, + ); + dom.createSvgElement( + Svg.PATH, + { + 'fill': 'none', + 'stroke': 'black', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'stroke-width': '2', + 'd': 'm18 9l3 3l-3 3m-3-3h6M6 9l-3 3l3 3m-3-3h6m0 6l3 3l3-3m-3-3v6m3-15l-3-3l-3 3m3-3v6', + 'transform': `translate(${(rtl ? -4 : 1) * 8} 8)`, + }, + this.svgRoot, + ); + } + + /** + * Moves this indicator to the specified location. + * + * @param x The location on the X axis to move to. + * @param y The location on the Y axis to move to. + */ + moveTo(x: number, y: number) { + this.svgRoot.setAttribute( + 'transform', + `translate(${x + (this.workspace.RTL ? 20 : -20)}, ${y - 20})`, + ); + } + + /** + * Disposes of this move indicator. + */ + dispose() { + dom.removeNode(this.svgRoot); + } +} diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index caa8ea84c..852521549 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -15,8 +15,10 @@ import {getFocusManager} from './focus_manager.js'; import {hasContextMenu} from './interfaces/i_contextmenu.js'; 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 {type IDraggable, isDraggable} from './interfaces/i_draggable.js'; +import {type IFocusableNode} from './interfaces/i_focusable_node.js'; +import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js'; +import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; import {Coordinate} from './utils/coordinate.js'; import {KeyCodes} from './utils/keycodes.js'; @@ -374,6 +376,124 @@ export function registerRedo() { ShortcutRegistry.registry.register(redoShortcut); } +/** + * Registers keyboard shortcuts for keyboard-driven movement of workspace + * elements. + */ +export function registerMovementShortcuts() { + const getCurrentDraggable = ( + workspace: WorkspaceSvg, + ): IDraggable | undefined => { + const node = getFocusManager().getFocusedNode(); + if (isDraggable(node)) return node; + return workspace.getCursor().getSourceBlock() ?? undefined; + }; + + const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [ + { + name: 'start_move', + preconditionFn: (workspace) => { + const startDraggable = getCurrentDraggable(workspace); + return !!startDraggable && KeyboardMover.mover.canMove(startDraggable); + }, + callback: (workspace, e) => { + keyboardNavigationController.setIsActive(true); + const startDraggable = getCurrentDraggable(workspace); + // Focus the root draggable in case one of its children + // was focused when the move was triggered. + if (startDraggable) { + getFocusManager().focusNode(startDraggable); + } + return ( + !!startDraggable && + KeyboardMover.mover.startMove(startDraggable, e as KeyboardEvent) + ); + }, + keyCodes: [KeyCodes.M], + }, + { + name: 'finish_move', + preconditionFn: () => KeyboardMover.mover.isMoving(), + callback: (_workspace, e) => + KeyboardMover.mover.finishMove(e as KeyboardEvent), + keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE], + allowCollision: true, + }, + { + name: 'abort_move', + preconditionFn: () => KeyboardMover.mover.isMoving(), + callback: (_workspace, e) => + KeyboardMover.mover.abortMove(e as KeyboardEvent), + keyCodes: [KeyCodes.ESC], + allowCollision: true, + }, + { + name: 'move_left', + preconditionFn: () => KeyboardMover.mover.isMoving(), + callback: (_workspace, e) => { + e.preventDefault(); + return KeyboardMover.mover.move(Direction.LEFT, e as KeyboardEvent); + }, + keyCodes: [ + KeyCodes.LEFT, + ShortcutRegistry.registry.createSerializedKey(KeyCodes.LEFT, [ + KeyCodes.CTRL_CMD, + ]), + ], + allowCollision: true, + }, + { + name: 'move_right', + preconditionFn: () => KeyboardMover.mover.isMoving(), + callback: (_workspace, e) => { + e.preventDefault(); + return KeyboardMover.mover.move(Direction.RIGHT, e as KeyboardEvent); + }, + keyCodes: [ + KeyCodes.RIGHT, + ShortcutRegistry.registry.createSerializedKey(KeyCodes.RIGHT, [ + KeyCodes.CTRL_CMD, + ]), + ], + allowCollision: true, + }, + { + name: 'move_up', + preconditionFn: () => KeyboardMover.mover.isMoving(), + callback: (_workspace, e) => { + e.preventDefault(); + return KeyboardMover.mover.move(Direction.UP, e as KeyboardEvent); + }, + keyCodes: [ + KeyCodes.UP, + ShortcutRegistry.registry.createSerializedKey(KeyCodes.UP, [ + KeyCodes.CTRL_CMD, + ]), + ], + allowCollision: true, + }, + { + name: 'move_down', + preconditionFn: () => KeyboardMover.mover.isMoving(), + callback: (_workspace, e) => { + e.preventDefault(); + return KeyboardMover.mover.move(Direction.DOWN, e as KeyboardEvent); + }, + keyCodes: [ + KeyCodes.DOWN, + ShortcutRegistry.registry.createSerializedKey(KeyCodes.DOWN, [ + KeyCodes.CTRL_CMD, + ]), + ], + allowCollision: true, + }, + ]; + + for (const shortcut of shortcuts) { + ShortcutRegistry.registry.register(shortcut); + } +} + /** * Keyboard shortcut to show the context menu on ctrl/cmd+Enter. */ @@ -418,6 +538,7 @@ export function registerDefaultShortcuts() { registerUndo(); registerRedo(); registerShowContextMenu(); + registerMovementShortcuts(); } registerDefaultShortcuts(); diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index c69322597..657a94d46 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -59,6 +59,7 @@ 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'; import type {IToolbox} from './interfaces/i_toolbox.js'; +import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import type {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Marker} from './keyboard_nav/marker.js'; import {LayerManager} from './layer_manager.js'; @@ -320,9 +321,6 @@ export class WorkspaceSvg /** True if keyboard accessibility mode is on, false otherwise. */ keyboardAccessibilityMode = false; - /** True iff a keyboard-initiated move ("drag") is in progress. */ - keyboardMoveInProgress = false; // TODO(#8960): Delete this. - /** The list of top-level bounded elements on the workspace. */ private topBoundedElements: IBoundedElement[] = []; @@ -1427,13 +1425,17 @@ export class WorkspaceSvg /** * Returns the drag target the pointer event is over. * - * @param e Pointer move event. + * @param e Pointer move event or a workspace coordinate. * @returns Null if not over a drag target, or the drag target the event is * over. */ - getDragTarget(e: PointerEvent): IDragTarget | null { + getDragTarget(e: PointerEvent | Coordinate): IDragTarget | null { + const coordinate = + e instanceof Coordinate + ? svgMath.wsToScreenCoordinates(this, e) + : new Coordinate(e.clientX, e.clientY); for (let i = 0, targetArea; (targetArea = this.dragTargetAreas[i]); i++) { - if (targetArea.clientRect.contains(e.clientX, e.clientY)) { + if (targetArea.clientRect.contains(coordinate.x, coordinate.y)) { return targetArea.component; } } @@ -1472,27 +1474,6 @@ export class WorkspaceSvg return drag.move(this, e); } - /** - * Indicate whether a keyboard move is in progress or not. - * - * Should be called with true when a keyboard move of an IDraggable - * is starts, and false when it finishes or is aborted. - * - * N.B.: This method is experimental and internal-only. It is - * intended only to called only from the keyboard navigation plugin. - * Its signature and behaviour may be modified, or the method - * removed, at an time without notice and without being treated - * as a breaking change. - * - * TODO(#8960): Delete this. - * - * @internal - * @param inProgress Is a keyboard-initated move in progress? - */ - setKeyboardMoveInProgress(inProgress: boolean) { - this.keyboardMoveInProgress = inProgress; - } - /** * Returns true iff the user is currently engaged in a drag gesture, * or if a keyboard-initated move is in progress. @@ -1509,9 +1490,7 @@ export class WorkspaceSvg */ isDragging(): boolean { return ( - // TODO(#8960): Query Mover.isMoving to see if move is in - // progress rather than relying on a status flag. - this.keyboardMoveInProgress || + KeyboardMover.mover.isMoving() || (this.currentGesture_ !== null && this.currentGesture_.isDragging()) ); } @@ -2468,10 +2447,9 @@ export class WorkspaceSvg * @internal */ getGesture(e?: PointerEvent): Gesture | null { - // TODO(#8960): Query Mover.isMoving to see if move is in progress - // rather than relying on .keyboardMoveInProgress status flag. - if (this.keyboardMoveInProgress) { - // Normally these would be called from Gesture.doStart. + // Ignore and cancel events that would start a gesture during a + // keyboard-driven move. + if (KeyboardMover.mover.isMoving()) { e?.preventDefault(); e?.stopPropagation(); return null; diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index efa06f10c..814443756 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-01-08 08:39:56.707280", + "lastupdated": "2026-02-12 13:23:33.999357", "locale": "en", "messagedocumentation" : "qqq" }, @@ -409,6 +409,7 @@ "COMMAND_KEY": "⌘ Command", "OPTION_KEY": "⌥ Option", "ALT_KEY": "Alt", + "ENTER_KEY": "Enter", "CUT_SHORTCUT": "Cut", "COPY_SHORTCUT": "Copy", "PASTE_SHORTCUT": "Paste", diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 6912c7fd5..8e7be38b3 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -416,6 +416,7 @@ "COMMAND_KEY": "Representation of the Mac Command key used in keyboard shortcuts.", "OPTION_KEY": "Representation of the Mac Option key used in keyboard shortcuts.", "ALT_KEY": "Representation of the Alt key used in keyboard shortcuts.", + "ENTER_KEY": "Representation of the Enter key used in keyboard shortcuts.", "CUT_SHORTCUT": "menu label - Contextual menu item that cuts the focused item.", "COPY_SHORTCUT": "menu label - Contextual menu item that copies the focused item.", "PASTE_SHORTCUT": "menu label - Contextual menu item that pastes the previously copied item.", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 0cc4d3be4..6ae66c40a 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1626,7 +1626,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} */ @@ -1658,6 +1658,9 @@ Blockly.Msg.OPTION_KEY = '⌥ Option'; /// Representation of the Alt key used in keyboard shortcuts. Blockly.Msg.ALT_KEY = 'Alt'; /** @type {string} */ +/// Representation of the Enter key used in keyboard shortcuts. +Blockly.Msg.ENTER_KEY = 'Enter'; +/** @type {string} */ /// menu label - Contextual menu item that cuts the focused item. Blockly.Msg.CUT_SHORTCUT = 'Cut'; /** @type {string} */ diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index 8dd5417eb..012bfe201 100644 --- a/packages/blockly/tests/mocha/index.html +++ b/packages/blockly/tests/mocha/index.html @@ -219,6 +219,7 @@ import './jso_deserialization_test.js'; import './jso_serialization_test.js'; import './json_test.js'; + import './keyboard_movement_test.js'; import './keyboard_navigation_controller_test.js'; import './layering_test.js'; import './blocks/lists_test.js'; diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js new file mode 100644 index 000000000..b4ad43d71 --- /dev/null +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -0,0 +1,861 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from '../../build/src/core/blockly.js'; +import {assert} from '../../node_modules/chai/index.js'; +import { + moveStatementTestBlocks, + moveValueTestBlocks, +} from './test_helpers/move_test_blocks.js'; +import {p5blocks} from './test_helpers/p5_blocks.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; +import {createKeyDownEvent} from './test_helpers/user_input.js'; + +suite('Keyboard-driven movement', function () { + setup(function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', {toolbox: toolbox}); + Blockly.common.defineBlocks(p5blocks); + Blockly.KeyboardMover.mover.setMoveDistance(20); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + function startMove(workspace) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.M); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function moveUp(workspace, modifiers) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.UP, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function moveDown(workspace, modifiers) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.DOWN, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function moveLeft(workspace, modifiers) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.LEFT, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function moveRight(workspace, modifiers) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.RIGHT, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function cancelMove(workspace) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ESC); + workspace.getInjectionDiv().dispatchEvent(event); + } + + function endMove(workspace) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + workspace.getInjectionDiv().dispatchEvent(event); + } + + /** + * Create a new block from serialised state (parsed JSON) and + * optionally attach it to an existing block on the workspace. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace to create the block + * on. + * @param {!Blockly.serialization.blocks.State} state The JSON definition of + * the new block. + * @param {?string} parentId The ID of the block to attach to. If undefined, + * the new block is not attached. + * @param {?string} inputName The name of the input on the parent block to + * attach to. If undefined, the new block is attached to the + * parent's next connection. + * @returns {!Promise} A promise that resolves with the new block's + * ID. + */ + function appendBlock(workspace, state, parentId, inputName) { + const block = Blockly.serialization.blocks.append(state, workspace); + if (!block) throw new Error('failed to create block from state'); + if (!parentId) return block.id; + + try { + const parent = workspace.getBlockById(parentId); + if (!parent) throw new Error(`parent block not found: ${parentId}`); + + let parentConnection; + let childConnection; + + if (inputName) { + parentConnection = parent.getInput(inputName)?.connection; + if (!parentConnection) { + throw new Error(`input ${inputName} not found on parent`); + } + childConnection = block.outputConnection ?? block.previousConnection; + } else { + parentConnection = parent.nextConnection; + if (!parentConnection) { + throw new Error('parent has no next connection'); + } + childConnection = block.previousConnection; + } + if (!childConnection) throw new Error('new block not compatible'); + parentConnection.connect(childConnection); + return block.id; + } catch (e) { + // If anything goes wrong during attachment, clean up the new block. + block.dispose(); + throw e; + } + } + + /** + * Get information about the currently-focused block's parent and + * child blocks. + * + * @returns {!Promise<{parentId: string | null, parentIndex: number | null, nextId: string | null, valueId: string | null}>} A promise resolving to + * + * {parentId, parentIndex, nextId, valueId} + * + * where parentId, parentIndex are the ID of the parent block and + * the index of the connection on that block to which the + * currently-focused block is connected, nextId is the ID of block + * connected to the focused block's next connection, and valueID + * is the ID of a block connected to the zeroth input of the + * focused block (or, in each case, null if there is no such + * block). + */ + async function getFocusedNeighbourInfo() { + return Blockly.renderManagement.finishQueuedRenders().then(() => { + const block = Blockly.getFocusManager().getFocusedNode(); + if (!block) throw new Error('nothing focused'); + if (!(block instanceof Blockly.BlockSvg)) { + throw new TypeError('focused node is not a BlockSvg'); + } + const parent = block?.getParent(); + return { + parentId: parent?.id ?? null, + parentIndex: + parent + ?.getConnections_(true) + .findIndex((conn) => conn.targetBlock() === block) ?? null, + nextId: block?.getNextBlock()?.id ?? null, + valueId: block?.inputList[0].connection?.targetBlock()?.id ?? null, + }; + }); + } + + /** + * Get information about the connection candidate for the + * currently-moving block (if any). + * + * @returns {!Promise<{id: string, index: number, ownIndex: number} | null>} A + * promise resolving to either null if there is no connection + * candidate, or otherwise if there is one to + * + * {id, index, ownIndex} + * + * where id is the block ID of the neighbour, index is the index + * of the candidate connection on the neighbour, and ownIndex is + * the index of the candidate connection on the moving block. + */ + function getConnectionCandidate() { + const focused = Blockly.getFocusManager().getFocusedNode(); + if (!focused) throw new Error('nothing focused'); + if (!(focused instanceof Blockly.BlockSvg)) { + throw new TypeError('focused node is not a BlockSvg'); + } + const block = focused; // Inferred as BlockSvg. + const dragStrategy = block.getDragStrategy(); + if (!dragStrategy) throw new Error('no drag strategy'); + const candidate = dragStrategy.connectionCandidate; + if (!candidate) return null; + const neighbourBlock = candidate.neighbour.getSourceBlock(); + if (!neighbourBlock) throw new TypeError('connection has no source block'); + const neighbourConnections = neighbourBlock.getConnections_(true); + const index = neighbourConnections.indexOf(candidate.neighbour); + const ownConnections = block.getConnections_(true); + const ownIndex = ownConnections.indexOf(candidate.local); + return {id: neighbourBlock.id, index, ownIndex}; + } + + /** + * Create a mocha test function moving a specified block in a + * particular direction, checking that it has the the expected + * connection candidate after each step, and that once the move + * finishes that the moving block is reconnected to its initial + * location. + * + * @param {!string} mover Block ID of the block to be moved. + * @param {!Blockly.utils.KeyCodes} key Key to send to move one step. + * @param {!Blockly.RenderedConnection[]} candidates Array of expected + * connection candidates. + * @returns {!function} function to pass as second argument to mocha's test + * function. + */ + function moveTest(mover, key, candidates) { + return async function () { + // Navigate to block to be moved and initiate move. + const block = this.workspace.getBlockById(mover); + Blockly.getFocusManager().focusNode(block); + const initialInfo = await getFocusedNeighbourInfo(); + startMove(this.workspace); + // Press specified key multiple times, checking connection candidates. + for (let i = 0; i < candidates.length; i++) { + const candidate = getConnectionCandidate(); + assert.deepEqual(candidate, candidates[i]); + const event = createKeyDownEvent(key); + this.workspace.getInjectionDiv().dispatchEvent(event); + } + + // Finish move and check final location of moved block. + endMove(this.workspace); + const finalInfo = await getFocusedNeighbourInfo(); + assert.deepEqual(initialInfo, finalInfo); + }; + } + + function testMovingUp() { + test('can move them up', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveUp(this.workspace, this.modifiers); + endMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.isBelow(newBounds.top, originalBounds.top); + assert.equal(newBounds.left, originalBounds.left); + }); + } + + function testMovingDown() { + test('can move them down', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveDown(this.workspace, this.modifiers); + endMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.isAbove(newBounds.bottom, originalBounds.bottom); + assert.equal(newBounds.left, originalBounds.left); + }); + } + + function testMovingLeft() { + test('can move them left', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveLeft(this.workspace, this.modifiers); + endMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.isBelow(newBounds.left, originalBounds.left); + assert.equal(newBounds.top, originalBounds.top); + }); + } + + function testMovingRight() { + test('can move them right', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveRight(this.workspace, this.modifiers); + endMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.isAbove(newBounds.right, originalBounds.right); + assert.equal(newBounds.top, originalBounds.top); + }); + } + + function testCancelingMove() { + test('can be cancelled', function () { + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveRight(this.workspace, this.modifiers); + moveUp(this.workspace, this.modifiers); + cancelMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.deepEqual(newBounds, originalBounds); + }); + } + + function testMoveIndicatorIsDisplayed() { + test('displays an attached move indicator while moving', function () { + Blockly.getFocusManager().focusNode(this.element); + assert.equal( + this.workspace + .getInjectionDiv() + .querySelectorAll('.blocklyMoveIndicator').length, + 0, + ); + startMove(this.workspace); + moveRight(this.workspace, this.modifiers); + assert.equal( + this.workspace + .getInjectionDiv() + .querySelectorAll('.blocklyMoveIndicator').length, + 1, + ); + endMove(this.workspace); + assert.equal( + this.workspace + .getInjectionDiv() + .querySelectorAll('.blocklyMoveIndicator').length, + 0, + ); + }); + } + + function testAdjustingMoveStepSize() { + test('respects configured step size', function () { + Blockly.getFocusManager().focusNode(this.element); + startMove(this.workspace); + const steps = [100, 20, 0, -20, -100]; + for (const step of steps) { + Blockly.KeyboardMover.mover.setMoveDistance(step); + const oldLeft = this.element.getBoundingRectangle().left; + moveRight(this.workspace, this.modifiers); + const newLeft = this.element.getBoundingRectangle().left; + assert.equal(newLeft - oldLeft, step); + } + endMove(this.workspace); + }); + } + + function testUnrelatedShortcutCommits() { + test('is committed when unrelated shortcuts are performed', function () { + const oldBounds = this.element.getBoundingRectangle(); + Blockly.getFocusManager().focusNode(this.element); + startMove(this.workspace); + assert.isTrue(Blockly.KeyboardMover.mover.isMoving()); + moveRight(this.workspace, this.modifiers); + moveRight(this.workspace, this.modifiers); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.isFalse(Blockly.KeyboardMover.mover.isMoving()); + + const newBounds = this.element.getBoundingRectangle(); + oldBounds.left += 40; + oldBounds.right += 40; + assert.deepEqual(newBounds, oldBounds); + }); + } + + function testExemptedShortcutsAllowed() { + test('is not committed when allowlisted shortcuts are performed', function () { + const hotkey = Blockly.ShortcutRegistry.registry.createSerializedKey( + Blockly.utils.KeyCodes.M, + [Blockly.utils.KeyCodes.CTRL_CMD], + ); + + let shortcutRun = false; + const testShortcut = { + name: 'test_shortcut', + preconditionFn: () => true, + callback: () => { + shortcutRun = true; + return true; + }, + keyCodes: [hotkey], + }; + Blockly.ShortcutRegistry.registry.register(testShortcut); + + Blockly.getFocusManager().focusNode(this.element); + startMove(this.workspace); + assert.isTrue(Blockly.KeyboardMover.mover.isMoving()); + moveRight(this.workspace, this.modifiers); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.M, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]); + this.workspace.getInjectionDiv().dispatchEvent(event); + // Move mode should still be active and the shortcut should have toggled + // the `shortcutRun` variable. + assert.isTrue(Blockly.KeyboardMover.mover.isMoving()); + assert.isTrue(shortcutRun); + cancelMove(this.workspace); + Blockly.ShortcutRegistry.registry.unregister('test_shortcut'); + }); + } + + suite('of workspace comments', function () { + setup(function () { + this.element = new Blockly.comments.RenderedWorkspaceComment( + this.workspace, + ); + }); + + testMovingUp(); + testMovingDown(); + testMovingLeft(); + testMovingRight(); + testCancelingMove(); + testMoveIndicatorIsDisplayed(); + testAdjustingMoveStepSize(); + testUnrelatedShortcutCommits(); + testExemptedShortcutsAllowed(); + }); + + suite('of blocks', function () { + setup(function () { + this.element = this.workspace.newBlock('logic_boolean'); + this.element.initSvg(); + this.element.render(); + this.modifiers = [Blockly.utils.KeyCodes.CTRL_CMD]; + }); + + suite('in unconstrained mode', function () { + testMovingUp(); + testMovingDown(); + testMovingLeft(); + testMovingRight(); + testCancelingMove(); + testMoveIndicatorIsDisplayed(); + testAdjustingMoveStepSize(); + testUnrelatedShortcutCommits(); + testExemptedShortcutsAllowed(); + }); + + suite('in constrained mode', function () { + test('prompts to use unconstrained mode when no destinations are available', function () { + const toastSpy = sinon.spy(Blockly.Toast, 'show'); + Blockly.getFocusManager().focusNode(this.element); + const originalBounds = this.element.getBoundingRectangle(); + startMove(this.workspace); + moveUp(this.workspace); + endMove(this.workspace); + const newBounds = this.element.getBoundingRectangle(); + assert.deepEqual(newBounds, originalBounds); + assert.equal( + toastSpy.args[0][1]['message'], + Blockly.utils.userAgent.MAC + ? 'Hold ⌘ Command and use arrow keys to move freely, then Enter to accept the position' + : 'Hold Ctrl and use arrow keys to move freely, then Enter to accept the position', + ); + toastSpy.restore(); + }); + + suite('Statement move tests', function () { + // Clear the workspace and load start blocks. + setup(function () { + Blockly.serialization.workspaces.load( + moveStatementTestBlocks, + this.workspace, + ); + }); + + /** Serialized simple statement block with no statement inputs. */ + const STATEMENT_SIMPLE = { + type: 'draw_emoji', + id: 'simple_mover', + fields: {emoji: '✨'}, + }; + /** + * Expected connection candidates when moving a block with no + * inputs, after pressing right (or down) arrow n times. + */ + const EXPECTED_SIMPLE_RIGHT = [ + {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location. + {id: 'text_print', index: 0, ownIndex: 1}, // Previous. + {id: 'text_print', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input. + {id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input. + {id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input. + {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. + {id: 'controls_if', index: 1, ownIndex: 0}, // Next. + {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + ]; + /** + * Expected connection candidates when moving STATEMENT_SIMPLE after + * pressing left (or up) arrow n times. + */ + const EXPECTED_SIMPLE_LEFT = EXPECTED_SIMPLE_RIGHT.slice(0, 1).concat( + EXPECTED_SIMPLE_RIGHT.slice(1).reverse(), + ); + + suite('Constrained moves of simple statement block', function () { + setup(function () { + appendBlock(this.workspace, STATEMENT_SIMPLE, 'p5_canvas'); + }); + test( + 'moving right', + moveTest( + STATEMENT_SIMPLE.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_SIMPLE_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + STATEMENT_SIMPLE.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_SIMPLE_LEFT, + ), + ); + test( + 'moving down', + moveTest( + STATEMENT_SIMPLE.id, + Blockly.utils.KeyCodes.DOWN, + EXPECTED_SIMPLE_RIGHT, + ), + ); + test( + 'moving up', + moveTest( + STATEMENT_SIMPLE.id, + Blockly.utils.KeyCodes.UP, + EXPECTED_SIMPLE_LEFT, + ), + ); + }); + + /** Serialized statement block with multiple statement inputs. */ + const STATEMENT_COMPLEX = { + type: 'controls_if', + id: 'complex_mover', + extraState: {hasElse: true}, + }; + /** + * Expected connection candidates when moving STATEMENT_COMPLEX, after + * pressing right (or down) arrow n times. + */ + const EXPECTED_COMPLEX_RIGHT = [ + // TODO(#702): With the current behavior, certain connection + // candidates that can be found using the mouse are not visited when + // doing a keyboard move. They appear in the list below, but commented + // out for now. They should be uncommented if the behavior is changed. + {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location again. + // {id: 'text_print', index: 0, ownIndex: 1}, // Previous to own next. + {id: 'text_print', index: 0, ownIndex: 4}, // Previous to own else input. + // {id: 'text_print', index: 0, ownIndex: 3}, // Previous to own if input. + {id: 'text_print', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input. + {id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input. + {id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input. + {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. + {id: 'controls_if', index: 1, ownIndex: 0}, // Next. + {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + ]; + /** + * Expected connection candidates when moving STATEMENT_COMPLEX after + * pressing left or up arrow n times. + */ + const EXPECTED_COMPLEX_LEFT = EXPECTED_COMPLEX_RIGHT.slice(0, 1).concat( + EXPECTED_COMPLEX_RIGHT.slice(1).reverse(), + ); + + suite( + 'Constrained moves of stack block with statement inputs', + function () { + setup(function () { + appendBlock(this.workspace, STATEMENT_COMPLEX, 'p5_canvas'); + }); + test( + 'moving right', + moveTest( + STATEMENT_COMPLEX.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_COMPLEX_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + STATEMENT_COMPLEX.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_COMPLEX_LEFT, + ), + ); + test( + 'moving down', + moveTest( + STATEMENT_COMPLEX.id, + Blockly.utils.KeyCodes.DOWN, + EXPECTED_COMPLEX_RIGHT, + ), + ); + test( + 'moving up', + moveTest( + STATEMENT_COMPLEX.id, + Blockly.utils.KeyCodes.UP, + EXPECTED_COMPLEX_LEFT, + ), + ); + }, + ); + + // When a top-level block with no previous, next or output + // connections is subject to a constrained move, it should not move. + // + // This includes a regression test for issue #446 (fixed in PR #599) + // where, due to an implementation error in Mover, constrained + // movement following unconstrained movement would result in the + // block unexpectedly moving (unless workspace scale was === 1). + test('Constrained move of unattachable top-level block', async function () { + // Block ID of an unconnectable block. + const BLOCK = Blockly.getMainWorkspace().getBlockById('p5_setup'); + + // Scale workspace. + this.workspace.setScale(0.9); + + // Navigate to unconnectable block, get initial coords and start move. + Blockly.getFocusManager().focusNode(BLOCK); + const startCoordinate = BLOCK.getBoundingRectangle(); + startMove(this.workspace); + + // Check constrained moves have no effect. + for (let i = 0; i < 5; i++) { + moveDown(this.workspace); + } + const coordinate = BLOCK.getBoundingRectangle(); + assert.deepEqual( + coordinate, + startCoordinate, + 'constrained move should have no effect', + ); + cancelMove(this.workspace); + }); + }); + + suite(`Value expression move tests`, function () { + /** Serialized simple reporter value block with no inputs. */ + const VALUE_SIMPLE = { + type: 'text', + id: 'simple_mover', + fields: {TEXT: 'simple mover'}, + }; + /** + * Expected connection candidates when moving VALUE_SIMPLE, after + * pressing ArrowRight n times. + */ + const EXPECTED_SIMPLE_RIGHT = [ + {id: 'join0', index: 1, ownIndex: 0}, // Join block ADD0 input. + {id: 'join0', index: 2, ownIndex: 0}, // Join block ADD1 input. + {id: 'print1', index: 2, ownIndex: 0}, // Print block with no shadow. + {id: 'print2', index: 2, ownIndex: 0}, // Print block with shadow. + // Skip draw_emoji block as it has no value inputs. + {id: 'print3', index: 2, ownIndex: 0}, // Replacing join expression. + {id: 'join1', index: 1, ownIndex: 0}, // Join block ADD0 input. + {id: 'join1', index: 2, ownIndex: 0}, // Join block ADD1 input. + // Skip controls_repeat_ext block's TIMES input as it is incompatible. + {id: 'print4', index: 2, ownIndex: 0}, // Replacing join expression. + {id: 'join2', index: 1, ownIndex: 0}, // Join block ADD0 input. + {id: 'join2', index: 2, ownIndex: 0}, // Join block ADD1 input. + // Skip input of unattached join block. + ]; + /** + * Expected connection candidates when moving BLOCK_SIMPLE, after + * pressing ArrowLeft n times. + */ + const EXPECTED_SIMPLE_LEFT = EXPECTED_SIMPLE_RIGHT.slice(0, 1).concat( + EXPECTED_SIMPLE_RIGHT.slice(1).reverse(), + ); + + /** + * Serialized row of value blocks with no free inputs; should behave + * as VALUE_SIMPLE does. + */ + const VALUE_ROW = { + type: 'text_changeCase', + id: 'row_mover', + fields: {CASE: 'TITLECASE'}, + inputs: { + TEXT: {block: VALUE_SIMPLE}, + }, + }; + // EXPECTED_ROW_RIGHT will be same as EXPECTED_SIMPLE_RIGHT (and + // same for ..._LEFT). + + /** Serialized value block with a single free (external) input. */ + const VALUE_UNARY = { + type: 'text_changeCase', + id: 'unary_mover', + fields: {CASE: 'TITLECASE'}, + }; + /** + * Expected connection candidates when moving VALUE_UNARY after + * pressing ArrowRight n times. + */ + const EXPECTED_UNARY_RIGHT = EXPECTED_SIMPLE_RIGHT.concat([ + {id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input. + ]); + /** + * Expected connection candidates when moving row consisting of + * BLOCK_UNARY on its own after pressing ArrowLEFT n times. + */ + const EXPECTED_UNARY_LEFT = EXPECTED_UNARY_RIGHT.slice(0, 1).concat( + EXPECTED_UNARY_RIGHT.slice(1).reverse(), + ); + + /** Serialized value block with a single free (external) input. */ + const VALUE_COMPLEX = { + type: 'text_join', + id: 'complex_mover', + }; + /** + * Expected connection candidates when moving VALUE_COMPLEX after + * pressing ArrowRight n times. + */ + const EXPECTED_COMPLEX_RIGHT = EXPECTED_SIMPLE_RIGHT.concat([ + // TODO(#702): With the current behavior, certain connection + // candidates that can be found using the mouse are not visited when + // doing a keyboard move. They appear in the list below, but commented + // out for now. They should be uncommented if the behavior is changed. + {id: 'join0', index: 0, ownIndex: 2}, // Unattached block to own input. + // {id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input. + ]); + /** + * Expected connection candidates when moving row consisting of + * BLOCK_COMPLEX on its own after pressing ArrowLEFT n times. + */ + const EXPECTED_COMPLEX_LEFT = EXPECTED_COMPLEX_RIGHT.slice(0, 1).concat( + EXPECTED_COMPLEX_RIGHT.slice(1).reverse(), + ); + + for (const renderer of ['geras', 'thrasos', 'zelos']) { + suite(`using ${renderer}`, function () { + // Clear the workspace and load start blocks. + setup(function () { + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: renderer, + }); + Blockly.serialization.workspaces.load( + moveValueTestBlocks, + this.workspace, + ); + }); + + suite('Constrained moves of a simple reporter block', function () { + setup(function () { + appendBlock(this.workspace, VALUE_SIMPLE, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest( + VALUE_SIMPLE.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_SIMPLE_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + VALUE_SIMPLE.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_SIMPLE_LEFT, + ), + ); + }); + + suite('Constrained moves of row of value blocks', function () { + setup(function () { + appendBlock(this.workspace, VALUE_ROW, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest( + VALUE_ROW.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_SIMPLE_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + VALUE_ROW.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_SIMPLE_LEFT, + ), + ); + }); + + suite('Constrained moves of unary expression block', function () { + setup(function () { + appendBlock(this.workspace, VALUE_UNARY, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest( + VALUE_UNARY.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_UNARY_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + VALUE_UNARY.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_UNARY_LEFT, + ), + ); + }); + + suite( + 'Constrained moves of a complex expression block', + function () { + setup(function () { + appendBlock(this.workspace, VALUE_COMPLEX, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest( + VALUE_COMPLEX.id, + Blockly.utils.KeyCodes.RIGHT, + EXPECTED_COMPLEX_RIGHT, + ), + ); + test( + 'moving left', + moveTest( + VALUE_COMPLEX.id, + Blockly.utils.KeyCodes.LEFT, + EXPECTED_COMPLEX_LEFT, + ), + ); + }, + ); + }); + } + }); + }); + }); + + suite('of bubbles', function () { + setup(async function () { + const commentBlock = this.workspace.newBlock('logic_boolean'); + commentBlock.setCommentText('Hello world'); + const icon = commentBlock.getIcon(Blockly.icons.IconType.COMMENT); + await icon.setBubbleVisible(true); + this.element = icon.getBubble(); + }); + + testMovingUp(); + testMovingDown(); + testMovingLeft(); + testMovingRight(); + testCancelingMove(); + testMoveIndicatorIsDisplayed(); + testAdjustingMoveStepSize(); + testUnrelatedShortcutCommits(); + testExemptedShortcutsAllowed(); + }); +}); diff --git a/packages/blockly/tests/mocha/test_helpers/move_test_blocks.js b/packages/blockly/tests/mocha/test_helpers/move_test_blocks.js new file mode 100644 index 000000000..c29dab7e7 --- /dev/null +++ b/packages/blockly/tests/mocha/test_helpers/move_test_blocks.js @@ -0,0 +1,528 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +// The draw block contains a stack of statement blocks, each of which +// has a value input to which is connected a value expression block +// which itself has one or two inputs which have (non-shadow) simple +// value blocks connected. Each statement block will be selected in +// turn and then a move initiated (and then aborted). This is then +// repeated with the first level value blocks (those that are attached +// to the statement blocks). The second level value blocks are +// present to verify correct (lack of) heal behaviour. +const moveStartTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup_1', + 'x': 0, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas_1', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw_1', + 'x': 0, + 'y': 332, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'controls_if', + 'id': 'statement_1', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_operation', + 'id': 'value_1', + 'fields': { + 'OP': 'AND', + }, + 'inputs': { + 'A': { + 'block': { + 'type': 'logic_boolean', + 'id': 'value_1_1', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + 'B': { + 'block': { + 'type': 'logic_boolean', + 'id': 'value_1_2', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'statement_2', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_negate', + 'id': 'value_2', + 'inputs': { + 'BOOL': { + 'block': { + 'type': 'logic_boolean', + 'id': 'value_2_1', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'statement_3', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_3', + 'fields': { + 'NUM': 10, + }, + }, + 'block': { + 'type': 'math_arithmetic', + 'id': 'value_3', + 'fields': { + 'OP': 'ADD', + }, + 'inputs': { + 'A': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_3_1', + 'fields': { + 'NUM': 1, + }, + }, + 'block': { + 'type': 'math_number', + 'id': 'value_3_1', + 'fields': { + 'NUM': 0, + }, + }, + }, + 'B': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_3_2', + 'fields': { + 'NUM': 1, + }, + }, + 'block': { + 'type': 'math_number', + 'id': 'value_3_2', + 'fields': { + 'NUM': 0, + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'statement_4', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_4', + 'fields': { + 'NUM': 10, + }, + }, + 'block': { + 'type': 'math_trig', + 'id': 'value_4', + 'fields': { + 'OP': 'SIN', + }, + 'inputs': { + 'NUM': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_4_1', + 'fields': { + 'NUM': 45, + }, + }, + 'block': { + 'type': 'math_number', + 'id': 'value_4_1', + 'fields': { + 'NUM': 180, + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'statement_5', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_5', + 'fields': { + 'TEXT': 'abc', + }, + }, + 'block': { + 'type': 'text_join', + 'id': 'value_5', + 'extraState': { + 'itemCount': 2, + }, + 'inputs': { + 'ADD0': { + 'block': { + 'type': 'text', + 'id': 'value_5_1', + 'fields': { + 'TEXT': 'test', + }, + }, + }, + 'ADD1': { + 'block': { + 'type': 'text', + 'id': 'value_5_2', + 'fields': { + 'TEXT': 'test', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'statement_6', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_6', + 'fields': { + 'TEXT': 'abc', + }, + }, + 'block': { + 'type': 'text_reverse', + 'id': 'value_6', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_6_1', + 'fields': { + 'TEXT': '', + }, + }, + 'block': { + 'type': 'text', + 'id': 'value_6_1', + 'fields': { + 'TEXT': 'test', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'draw_emoji', + 'id': 'statement_7', + 'fields': { + 'emoji': '❤️', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, +}; + +// A bunch of statement blocks. It is intended that statement blocks +// to be moved can be attached to the next connection of p5_canvas, +// and then be (constrained-)moved up, down, left and right to verify +// that they visit all the expected candidate connections. +const moveStatementTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup', + 'x': 75, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'text_print', + 'id': 'text_print', + 'disabledReasons': ['MANUALLY_DISABLED'], + 'x': 75, + 'y': 400, + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_text', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if', + 'extraState': { + 'elseIfCount': 1, + 'hasElse': true, + }, + 'inputs': { + 'DO0': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_math_number', + 'fields': { + 'NUM': 10, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw', + 'x': 75, + 'y': 950, + 'deletable': false, + }, + ], + }, +}; + +const moveValueTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup', + 'x': 75, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'text_join', + 'id': 'join0', + 'x': 75, + 'y': 200, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw', + 'x': 75, + 'y': 300, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'text_print', + 'id': 'print1', + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'print2', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_print2', + 'fields': { + 'TEXT': 'shadow', + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'draw_emoji', + 'id': 'draw_emoji', + 'fields': { + 'emoji': '🐻', + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'print3', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text_join', + 'id': 'join1', + 'inline': true, + 'inputs': { + 'ADD0': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_join', + 'fields': { + 'TEXT': 'inline', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_repeat', + 'fields': { + 'NUM': 1, + }, + }, + }, + 'DO': { + 'block': { + 'type': 'text_print', + 'id': 'print4', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text_join', + 'id': 'join2', + 'inline': false, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, +}; + +export {moveStartTestBlocks, moveStatementTestBlocks, moveValueTestBlocks}; diff --git a/packages/blockly/tests/mocha/test_helpers/p5_blocks.js b/packages/blockly/tests/mocha/test_helpers/p5_blocks.js new file mode 100644 index 000000000..ad3c6a293 --- /dev/null +++ b/packages/blockly/tests/mocha/test_helpers/p5_blocks.js @@ -0,0 +1,358 @@ +/* eslint-disable camelcase */ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from '../../../build/src/core/blockly.js'; + +// p5 Basic Setup Blocks + +const p5SetupJson = { + 'type': 'p5_setup', + 'message0': 'setup %1', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'STATEMENTS', + }, + ], + 'colour': 300, + 'tooltip': 'Setup the p5 canvas. This code is run once.', + 'helpUrl': '', +}; + +const p5Setup = { + init: function () { + this.jsonInit(p5SetupJson); + // The setup block can't be removed. + this.setDeletable(false); + }, +}; + +const p5DrawJson = { + 'type': 'p5_draw', + 'message0': 'draw %1', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'STATEMENTS', + }, + ], + 'colour': 300, + 'tooltip': 'Draw on the canvas. This code is run continuously.', + 'helpUrl': '', +}; + +const p5Draw = { + init: function () { + this.jsonInit(p5DrawJson); + // The draw block can't be removed. + this.setDeletable(false); + }, +}; + +const p5CanvasJson = { + 'type': 'p5_canvas', + 'message0': 'create canvas with width %1 height %2', + 'args0': [ + { + 'type': 'field_number', + 'name': 'WIDTH', + 'value': 400, + 'max': 400, + 'precision': 1, + }, + { + 'type': 'field_number', + 'name': 'HEIGHT', + 'value': 400, + 'max': 400, + 'precision': 1, + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 300, + 'tooltip': 'Create a p5 canvas of the specified size.', + 'helpUrl': '', +}; + +const p5Canvas = { + init: function () { + this.jsonInit(p5CanvasJson); + // The canvas block can't be moved or disconnected from its parent. + this.setMovable(false); + this.setDeletable(false); + }, +}; + +const buttonsJson = { + 'type': 'buttons', + 'message0': 'If %1 %2 Then %3 %4 more %5 %6 %7', + 'args0': [ + { + 'type': 'field_image', + 'name': 'BUTTON1', + 'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif', + 'width': 30, + 'height': 30, + 'alt': '*', + }, + { + 'type': 'input_value', + 'name': 'VALUE1', + 'check': '', + }, + { + 'type': 'field_image', + 'name': 'BUTTON2', + 'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif', + 'width': 30, + 'height': 30, + 'alt': '*', + }, + { + 'type': 'input_dummy', + 'name': 'DUMMY1', + 'check': '', + }, + { + 'type': 'input_value', + 'name': 'VALUE2', + 'check': '', + }, + { + 'type': 'input_statement', + 'name': 'STATEMENT1', + 'check': 'Number', + }, + { + 'type': 'field_image', + 'name': 'BUTTON3', + 'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif', + 'width': 30, + 'height': 30, + 'alt': '*', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', +}; + +const buttonsBlock = { + init: function () { + this.jsonInit(buttonsJson); + const clickHandler = function () { + console.log('clicking a button!'); + }; + this.getField('BUTTON1').setOnClickHandler(clickHandler); + this.getField('BUTTON2').setOnClickHandler(clickHandler); + this.getField('BUTTON3').setOnClickHandler(clickHandler); + }, +}; + +const background = { + 'type': 'p5_background_color', + 'message0': 'Set background color to %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'COLOR', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 195, + 'tooltip': 'Set the background color of the canvas', + 'helpUrl': '', +}; + +const stroke = { + 'type': 'p5_stroke', + 'message0': 'Set stroke color to %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'COLOR', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 195, + 'tooltip': 'Set the stroke color', + 'helpUrl': '', +}; + +const fill = { + 'type': 'p5_fill', + 'message0': 'Set fill color to %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'COLOR', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 195, + 'tooltip': 'Set the fill color', + 'helpUrl': '', +}; + +const ellipse = { + 'type': 'p5_ellipse', + 'message0': 'draw ellipse %1 x %2 y %3 width %4 height %5', + 'args0': [ + { + 'type': 'input_dummy', + }, + { + 'type': 'input_value', + 'name': 'X', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'Y', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'WIDTH', + 'check': 'Number', + }, + { + 'type': 'input_value', + 'name': 'HEIGHT', + 'check': 'Number', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'tooltip': 'Draw an ellipse on the canvas.', + 'helpUrl': 'https://p5js.org/reference/#/p5/ellipse', +}; + +const draw_emoji = { + 'type': 'draw_emoji', + 'tooltip': '', + 'helpUrl': '', + 'message0': 'draw %1 %2', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'emoji', + 'options': [ + ['❤️', '❤️'], + ['✨', '✨'], + ['🐻', '🐻'], + ], + }, + { + 'type': 'input_dummy', + 'name': '', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'inputsInline': true, +}; + +const simpleCircle = { + 'type': 'simple_circle', + 'tooltip': '', + 'helpUrl': '', + 'message0': 'draw %1 circle %2', + 'args0': [ + { + 'type': 'input_value', + 'name': 'COLOR', + }, + { + 'type': 'input_dummy', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'inputsInline': true, +}; + +const writeTextWithoutShadow = { + 'type': 'write_text_without_shadow', + 'tooltip': '', + 'helpUrl': '', + 'message0': 'write without shadow %1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'TEXT', + 'text': 'bit', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 225, +}; + +const writeTextWithShadow = { + 'type': 'write_text_with_shadow', + 'tooltip': '', + 'helpUrl': '', + 'message0': 'write with shadow %1', + 'args0': [ + { + 'type': 'input_value', + 'name': 'TEXT', + 'check': 'String', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 225, +}; + +const textBlock = { + 'type': 'text_only', + 'tooltip': '', + 'helpUrl': '', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'TEXT', + 'text': 'micro', + }, + ], + 'output': 'String', + 'colour': 225, +}; + +// Create the block definitions for all the JSON-only blocks. +// This does not register their definitions with Blockly. +const jsonBlocks = Blockly.common.createBlockDefinitionsFromJsonArray([ + background, + stroke, + fill, + ellipse, + draw_emoji, + simpleCircle, + writeTextWithoutShadow, + writeTextWithShadow, + textBlock, +]); + +export const p5blocks = { + 'p5_setup': p5Setup, + 'p5_draw': p5Draw, + 'p5_canvas': p5Canvas, + 'buttons_block': buttonsBlock, + ...jsonBlocks, +}; From e65ac7f7e227233a0ff479c8156998ba92e56839 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 5 Mar 2026 11:04:12 -0800 Subject: [PATCH 011/200] feat: Add a method to play beep tones (#9612) --- packages/blockly/core/workspace_audio.ts | 85 +++++++++++++++++++----- 1 file changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/blockly/core/workspace_audio.ts b/packages/blockly/core/workspace_audio.ts index 46f18ce61..7d27277a0 100644 --- a/packages/blockly/core/workspace_audio.ts +++ b/packages/blockly/core/workspace_audio.ts @@ -83,24 +83,10 @@ export class WorkspaceAudio { * @param opt_volume Volume of sound (0-1). */ async play(name: string, opt_volume?: number) { - if (this.muted || opt_volume === 0 || !this.context) { - return; - } + if (!this.isPlayingAllowed() || opt_volume === 0) 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; - - if (this.context.state === 'suspended') { - await this.context.resume(); - } + await this.prepareToPlay(); const source = this.context.createBufferSource(); const gainNode = this.context.createGain(); @@ -121,6 +107,73 @@ export class WorkspaceAudio { } } + /** + * Plays a beep at the given frequency. + * + * @param tone The frequency of the beep to play, in hertz. + * @param duration The duration of the beep, in seconds. Defaults to 0.2. + */ + async beep(tone: number, duration = 0.2) { + if (!this.isPlayingAllowed()) return; + await this.prepareToPlay(); + + const oscillator = this.context.createOscillator(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(tone, this.context.currentTime); + + const gainNode = this.context.createGain(); + gainNode.gain.setValueAtTime(0, this.context.currentTime); + // Fade in + gainNode.gain.linearRampToValueAtTime(0.5, this.context.currentTime + 0.01); + // Fade out + gainNode.gain.linearRampToValueAtTime( + 0, + this.context.currentTime + duration, + ); + + oscillator.connect(gainNode); + gainNode.connect(this.context.destination); + + oscillator.start(this.context.currentTime); + oscillator.stop(this.context.currentTime + duration); + } + + /** + * 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( + this: WorkspaceAudio, + ): this is WorkspaceAudio & Required<{context: AudioContext}> { + const now = new Date(); + + if ( + this.getMuted() || + !this.context || + (this.lastSound !== null && + now.getTime() - this.lastSound.getTime() < SOUND_LIMIT) + ) { + return false; + } + return true; + } + + /** + * Prepares to play audio by recording the time of the last play and resuming + * the audio context. + */ + private async prepareToPlay( + this: WorkspaceAudio & Required<{context: AudioContext}>, + ) { + this.lastSound = new Date(); + + if (this.context.state === 'suspended') { + await this.context.resume(); + } + } + /** * @param muted If true, mute sounds. Otherwise, play them. */ From a5a18d3894b0f234c0ec3ae4dcc721b440409aaf Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 5 Mar 2026 12:29:39 -0800 Subject: [PATCH 012/200] refactor!: Move responsibility for block creation out of flyouts (#9610) * refactor!: Move responsibility for block creation out of flyouts * chore: Clarify naming and documentation * fix: Make test less convoluted * refactor: Use serialization instead of zero-length drag to handle block clicks * fix: Fix undoing when dragging a block from the flyout * refactor: Make `getTargetBlock()` always return a value --- .../core/dragging/block_drag_strategy.ts | 64 ++++++++- packages/blockly/core/dragging/dragger.ts | 24 ++-- packages/blockly/core/flyout_base.ts | 131 ------------------ packages/blockly/core/flyout_horizontal.ts | 27 ---- packages/blockly/core/flyout_vertical.ts | 28 ---- packages/blockly/core/gesture.ts | 75 +++------- packages/blockly/core/interfaces/i_flyout.ts | 32 ----- packages/blockly/tests/mocha/gesture_test.js | 3 +- 8 files changed, 95 insertions(+), 289 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index d63eb2cab..cabf7beae 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -25,8 +25,10 @@ import * as layers from '../layers.js'; import * as registry from '../registry.js'; import {finishQueuedRenders} from '../render_management.js'; import type {RenderedConnection} from '../rendered_connection.js'; +import * as blocks from '../serialization/blocks.js'; import {Coordinate} from '../utils.js'; import * as dom from '../utils/dom.js'; +import * as svgMath from '../utils/svg_math.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; /** Represents a nearby valid connection. */ @@ -95,17 +97,73 @@ export class BlockDragStrategy implements IDragStrategy { this.block.isOwnMovable() && !this.block.isDeadOrDying() && !this.workspace.isReadOnly() && - // We never drag blocks in the flyout, only create new blocks that are - // dragged. - !this.block.isInFlyout + (!this.block.isInFlyout || + (this.block.isEnabled() && + !this.block.workspace.targetWorkspace?.isReadOnly())) ); } + /** + * Positions a cloned block on its new workspace. + * + * @param oldBlock The flyout block that was cloned. + * @param newBlock The new block to position. + */ + private positionNewBlock(oldBlock: BlockSvg, newBlock: BlockSvg) { + const screenCoordinate = svgMath.wsToScreenCoordinates( + oldBlock.workspace, + oldBlock.getRelativeToSurfaceXY(), + ); + const workspaceCoordinates = svgMath.screenToWsCoordinates( + newBlock.workspace, + screenCoordinate, + ); + newBlock.moveTo(workspaceCoordinates); + } + + /** + * Returns the block to use for the current drag operation. This may create + * and return a newly instantiated block when e.g. dragging from a flyout. + */ + protected getTargetBlock() { + if (this.block.isShadow()) { + const parent = this.block.getParent(); + if (parent) { + return parent; + } + } else if (this.block.isInFlyout && this.block.workspace.targetWorkspace) { + const rootBlock = this.block.getRootBlock(); + + const json = blocks.save(rootBlock); + if (json) { + const newBlock = blocks.appendInternal( + json, + this.block.workspace.targetWorkspace, + { + recordUndo: true, + }, + ) as BlockSvg; + eventUtils.setRecordUndo(false); + this.positionNewBlock(this.block, newBlock); + eventUtils.setRecordUndo(true); + + return newBlock; + } + } + + return this.block; + } + /** * Handles any setup for starting the drag, including disconnecting the block * from any parent blocks. */ startDrag(e?: PointerEvent | KeyboardEvent) { + const alternateTarget = this.getTargetBlock(); + if (alternateTarget !== this.block) { + return alternateTarget.startDrag(e); + } + this.dragging = true; this.fireDragStartEvent(); diff --git a/packages/blockly/core/dragging/dragger.ts b/packages/blockly/core/dragging/dragger.ts index f0bde64ce..96600ac4e 100644 --- a/packages/blockly/core/dragging/dragger.ts +++ b/packages/blockly/core/dragging/dragger.ts @@ -16,17 +16,13 @@ import type {IDragger} from '../interfaces/i_dragger.js'; import {isFocusableNode} from '../interfaces/i_focusable_node.js'; import * as registry from '../registry.js'; import {Coordinate} from '../utils/coordinate.js'; -import type {WorkspaceSvg} from '../workspace_svg.js'; export class Dragger implements IDragger { protected startLoc: Coordinate; protected dragTarget: IDragTarget | null = null; - constructor( - protected draggable: IDraggable, - protected workspace: WorkspaceSvg, - ) { + constructor(protected draggable: IDraggable) { this.startLoc = draggable.getRelativeToSurfaceXY(); } @@ -65,7 +61,7 @@ export class Dragger implements IDragger { /** Updates the drag target under the pointer (if there is one). */ protected updateDragTarget(coordinate: Coordinate) { - const newDragTarget = this.workspace.getDragTarget(coordinate); + const newDragTarget = this.draggable.workspace.getDragTarget(coordinate); if (this.dragTarget !== newDragTarget) { this.dragTarget?.onDragExit(this.draggable); newDragTarget?.onDragEnter(this.draggable); @@ -95,10 +91,10 @@ export class Dragger implements IDragger { coordinate: Coordinate, rootDraggable: IDraggable & IDeletable, ) { - const dragTarget = this.workspace.getDragTarget(coordinate); + const dragTarget = this.draggable.workspace.getDragTarget(coordinate); if (!dragTarget) return false; - const componentManager = this.workspace.getComponentManager(); + const componentManager = this.draggable.workspace.getComponentManager(); const isDeleteArea = componentManager.hasCapability( dragTarget.id, ComponentManager.Capability.DELETE_AREA, @@ -111,7 +107,7 @@ export class Dragger implements IDragger { /** Handles any drag cleanup. */ onDragEnd(e?: PointerEvent | KeyboardEvent) { const origGroup = eventUtils.getGroup(); - const dragTarget = this.workspace.getDragTarget( + const dragTarget = this.draggable.workspace.getDragTarget( this.draggable.getRelativeToSurfaceXY(), ); @@ -175,21 +171,21 @@ export class Dragger implements IDragger { coordinate: Coordinate, rootDraggable: IDraggable, ) { - const dragTarget = this.workspace.getDragTarget(coordinate); + const dragTarget = this.draggable.workspace.getDragTarget(coordinate); if (!dragTarget) return false; return dragTarget.shouldPreventMove(rootDraggable); } protected pixelsToWorkspaceUnits(pixelCoord: Coordinate): Coordinate { const result = new Coordinate( - pixelCoord.x / this.workspace.scale, - pixelCoord.y / this.workspace.scale, + pixelCoord.x / this.draggable.workspace.scale, + pixelCoord.y / this.draggable.workspace.scale, ); - if (this.workspace.isMutator) { + if (this.draggable.workspace.isMutator) { // If we're in a mutator, its scale is always 1, purely because of some // oddities in our rendering optimizations. The actual scale is the same // as the scale on the parent workspace. Fix that for dragging. - const mainScale = this.workspace.options.parentWorkspace!.scale; + const mainScale = this.draggable.workspace.options.parentWorkspace!.scale; result.scale(1 / mainScale); } return result; diff --git a/packages/blockly/core/flyout_base.ts b/packages/blockly/core/flyout_base.ts index fb774bd61..d89027ab4 100644 --- a/packages/blockly/core/flyout_base.ts +++ b/packages/blockly/core/flyout_base.ts @@ -11,7 +11,6 @@ */ // Former goog.module ID: Blockly.Flyout -import {BlockSvg} from './block_svg.js'; import * as browserEvents from './browser_events.js'; import {ComponentManager} from './component_manager.js'; import {DeleteArea} from './delete_area.js'; @@ -32,8 +31,6 @@ import * as registry from './registry.js'; 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 {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as idGenerator from './utils/idgenerator.js'; import {Svg} from './utils/svg.js'; @@ -52,17 +49,6 @@ export abstract class Flyout */ abstract position(): void; - /** - * Determine if a drag delta is toward the workspace, based on the position - * and orientation of the flyout. This is used in determineDragIntention_ to - * determine if a new block should be created or if the flyout should scroll. - * - * @param currentDragDeltaXY How far the pointer has - * moved from the position at mouse down, in pixel units. - * @returns True if the drag is toward the workspace. - */ - abstract isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean; - /** * Sets the translation of the flyout to match the scrollbars. * @@ -191,29 +177,6 @@ export abstract class Flyout * Height of flyout. */ protected height_ = 0; - // clang-format off - /** - * Range of a drag angle from a flyout considered "dragging toward - * workspace". Drags that are within the bounds of this many degrees from - * the orthogonal line to the flyout edge are considered to be "drags toward - * the workspace". - * - * @example - * - * ``` - * Flyout Edge Workspace - * [block] / <-within this angle, drags "toward workspace" | - * [block] ---- orthogonal to flyout boundary ---- | - * [block] \ | - * ``` - * - * The angle is given in degrees from the orthogonal. - * - * This is used to know when to create a new block and when to scroll the - * flyout. Setting it to 360 means that all drags create a new block. - */ - // clang-format on - protected dragAngleRange_ = 70; /** * The path around the background of the flyout, which will be filled with a @@ -790,47 +753,6 @@ export abstract class Flyout } } - /** - * Does this flyout allow you to create a new instance of the given block? - * Used for deciding if a block can be "dragged out of" the flyout. - * - * @param block The block to copy from the flyout. - * @returns True if you can create a new instance of the block, false - * otherwise. - * @internal - */ - isBlockCreatable(block: BlockSvg): boolean { - return block.isEnabled() && !this.getTargetWorkspace().isReadOnly(); - } - - /** - * Create a copy of this block on the workspace. - * - * @param originalBlock The block to copy from the flyout. - * @returns The newly created block. - * @throws {Error} if something went wrong with deserialization. - * @internal - */ - createBlock(originalBlock: BlockSvg): BlockSvg { - const targetWorkspace = this.targetWorkspace; - const svgRootOld = originalBlock.getSvgRoot(); - if (!svgRootOld) { - throw Error('oldBlock is not rendered'); - } - - // Clone the block. - const json = this.serializeBlock(originalBlock); - // Normally this resizes leading to weird jumps. Save it for terminateDrag. - targetWorkspace.setResizesEnabled(false); - const block = blocks.appendInternal(json, targetWorkspace, { - recordUndo: true, - }) as BlockSvg; - - this.positionNewBlock(originalBlock, block); - targetWorkspace.hideChaff(); - return block; - } - /** * Reflow flyout contents. */ @@ -851,59 +773,6 @@ export abstract class Flyout : false; } - /** - * Serialize a block to JSON. - * - * @param block The block to serialize. - * @returns A serialized representation of the block. - */ - protected serializeBlock(block: BlockSvg): blocks.State { - return blocks.save(block) as blocks.State; - } - - /** - * Positions a block on the target workspace. - * - * @param oldBlock The flyout block being copied. - * @param block The block to posiiton. - */ - private positionNewBlock(oldBlock: BlockSvg, block: BlockSvg) { - const targetWorkspace = this.targetWorkspace; - - // The offset in pixels between the main workspace's origin and the upper - // left corner of the injection div. - const mainOffsetPixels = targetWorkspace.getOriginOffsetInPixels(); - - // The offset in pixels between the flyout workspace's origin and the upper - // left corner of the injection div. - const flyoutOffsetPixels = this.workspace_.getOriginOffsetInPixels(); - - // The position of the old block in flyout workspace coordinates. - const oldBlockPos = oldBlock.getRelativeToSurfaceXY(); - // The position of the old block in pixels relative to the flyout - // workspace's origin. - oldBlockPos.scale(this.workspace_.scale); - - // The position of the old block in pixels relative to the upper left corner - // of the injection div. - const oldBlockOffsetPixels = Coordinate.sum( - flyoutOffsetPixels, - oldBlockPos, - ); - - // The position of the old block in pixels relative to the origin of the - // main workspace. - const finalOffset = Coordinate.difference( - oldBlockOffsetPixels, - mainOffsetPixels, - ); - // The position of the old block in main workspace coordinates. - finalOffset.scale(1 / targetWorkspace.scale); - - // No 'reason' provided since events are disabled. - block.moveTo(new Coordinate(finalOffset.x, finalOffset.y)); - } - /** * Returns the inflater responsible for constructing items of the given type. * diff --git a/packages/blockly/core/flyout_horizontal.ts b/packages/blockly/core/flyout_horizontal.ts index 47b7ab06a..abba3e605 100644 --- a/packages/blockly/core/flyout_horizontal.ts +++ b/packages/blockly/core/flyout_horizontal.ts @@ -18,7 +18,6 @@ import type {FlyoutItem} from './flyout_item.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import {Scrollbar} from './scrollbar.js'; -import type {Coordinate} from './utils/coordinate.js'; import {Rect} from './utils/rect.js'; import * as toolbox from './utils/toolbox.js'; import * as WidgetDiv from './widgetdiv.js'; @@ -271,32 +270,6 @@ export class HorizontalFlyout extends Flyout { } } - /** - * Determine if a drag delta is toward the workspace, based on the position - * and orientation of the flyout. This is used in determineDragIntention_ to - * determine if a new block should be created or if the flyout should scroll. - * - * @param currentDragDeltaXY How far the pointer has moved from the position - * at mouse down, in pixel units. - * @returns True if the drag is toward the workspace. - */ - override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean { - const dx = currentDragDeltaXY.x; - const dy = currentDragDeltaXY.y; - // Direction goes from -180 to 180, with 0 toward the right and 90 on top. - const dragDirection = (Math.atan2(dy, dx) / Math.PI) * 180; - - const range = this.dragAngleRange_; - // Check for up or down dragging. - if ( - (dragDirection < 90 + range && dragDirection > 90 - range) || - (dragDirection > -90 - range && dragDirection < -90 + range) - ) { - return true; - } - return false; - } - /** * Returns the bounding rectangle of the drag target area in pixel units * relative to viewport. diff --git a/packages/blockly/core/flyout_vertical.ts b/packages/blockly/core/flyout_vertical.ts index 968b7c024..97606e804 100644 --- a/packages/blockly/core/flyout_vertical.ts +++ b/packages/blockly/core/flyout_vertical.ts @@ -18,7 +18,6 @@ import type {FlyoutItem} from './flyout_item.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import {Scrollbar} from './scrollbar.js'; -import type {Coordinate} from './utils/coordinate.js'; import {Rect} from './utils/rect.js'; import * as toolbox from './utils/toolbox.js'; import * as WidgetDiv from './widgetdiv.js'; @@ -235,33 +234,6 @@ export class VerticalFlyout extends Flyout { } } - /** - * Determine if a drag delta is toward the workspace, based on the position - * and orientation of the flyout. This is used in determineDragIntention_ to - * determine if a new block should be created or if the flyout should scroll. - * - * @param currentDragDeltaXY How far the pointer has moved from the position - * at mouse down, in pixel units. - * @returns True if the drag is toward the workspace. - */ - override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean { - const dx = currentDragDeltaXY.x; - const dy = currentDragDeltaXY.y; - // Direction goes from -180 to 180, with 0 toward the right and 90 on top. - const dragDirection = (Math.atan2(dy, dx) / Math.PI) * 180; - - const range = this.dragAngleRange_; - // Check for left or right dragging. - if ( - (dragDirection < range && dragDirection > -range) || - dragDirection < -180 + range || - dragDirection > 180 - range - ) { - return true; - } - return false; - } - /** * Returns the bounding rectangle of the drag target area in pixel units * relative to viewport. diff --git a/packages/blockly/core/gesture.ts b/packages/blockly/core/gesture.ts index 9d617c4c6..8628f938f 100644 --- a/packages/blockly/core/gesture.ts +++ b/packages/blockly/core/gesture.ts @@ -34,9 +34,11 @@ import type {IFlyout} from './interfaces/i_flyout.js'; import type {IIcon} from './interfaces/i_icon.js'; import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import * as registry from './registry.js'; +import * as blocks from './serialization/blocks.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; import {Coordinate} from './utils/coordinate.js'; +import * as svgMath from './utils/svg_math.js'; import {WorkspaceDragger} from './workspace_dragger.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -255,40 +257,6 @@ export class Gesture { return false; } - /** - * Update this gesture to record whether a block is being dragged from the - * flyout. - * This function should be called on a pointermove event the first time - * the drag radius is exceeded. It should be called no more than once per - * gesture. If a block should be dragged from the flyout this function creates - * the new block on the main workspace and updates targetBlock_ and - * startWorkspace_. - * - * @returns True if a block is being dragged from the flyout. - */ - private updateIsDraggingFromFlyout(): boolean { - if (!this.targetBlock || !this.flyout?.isBlockCreatable(this.targetBlock)) { - return false; - } - if (!this.flyout.targetWorkspace) { - throw new Error(`Cannot update dragging from the flyout because the ' + - 'flyout's target workspace is undefined`); - } - - this.startWorkspace_ = this.flyout.targetWorkspace; - this.startWorkspace_.updateScreenCalculationsIfScrolled(); - // Start the event group now, so that the same event group is used for - // block creation and block dragging. - if (!eventUtils.getGroup()) { - eventUtils.setGroup(true); - } - // The start block is no longer relevant, because this is a drag. - this.startBlock = null; - this.targetBlock = this.flyout.createBlock(this.targetBlock); - getFocusManager().focusNode(this.targetBlock); - return true; - } - /** * Check whether to start a workspace drag. If a workspace is being dragged, * create the necessary WorkspaceDragger and start the drag. @@ -335,14 +303,10 @@ export class Gesture { } this.calledUpdateIsDragging = true; - // If we drag a block out of the flyout, it updates `common.getSelected` - // to return the new block. - if (this.flyout) this.updateIsDraggingFromFlyout(); - const selected = common.getSelected(); if (selected && isDraggable(selected) && selected.isMovable()) { this.dragging = true; - this.dragger = this.createDragger(selected, this.startWorkspace_); + this.dragger = this.createDragger(selected); this.dragger.onDragStart(e); this.dragger.onDrag(e, this.currentDragDeltaXY); } else { @@ -350,16 +314,13 @@ export class Gesture { } } - private createDragger( - draggable: IDraggable, - workspace: WorkspaceSvg, - ): IDragger { + private createDragger(draggable: IDraggable): IDragger { const DraggerClass = registry.getClassFromOptions( registry.Type.BLOCK_DRAGGER, this.creatorWorkspace.options, true, ); - return new DraggerClass!(draggable, workspace); + return new DraggerClass!(draggable); } /** @@ -896,16 +857,24 @@ export class Gesture { 'Cannot do a block click because the target block is ' + 'undefined', ); } - if (this.flyout.isBlockCreatable(this.targetBlock)) { - if (!eventUtils.getGroup()) { - eventUtils.setGroup(true); - } - const newBlock = this.flyout.createBlock(this.targetBlock); - newBlock.snapToGrid(); - newBlock.bumpNeighbours(); - // If a new block was added, make sure that it's correctly focused. - getFocusManager().focusNode(newBlock); + const json = blocks.save(this.targetBlock); + const targetWorkspace = this.flyout.targetWorkspace; + if (json && targetWorkspace) { + const screenCoordinate = svgMath.wsToScreenCoordinates( + this.flyout.getWorkspace(), + this.targetBlock.getRelativeToSurfaceXY(), + ); + const workspaceCoordinates = svgMath.screenToWsCoordinates( + targetWorkspace, + screenCoordinate, + ); + json.x = workspaceCoordinates.x; + json.y = workspaceCoordinates.y; + blocks.appendInternal(json, targetWorkspace, { + recordUndo: true, + }); + targetWorkspace.hideChaff(false); } } else { if (!this.startWorkspace_) { diff --git a/packages/blockly/core/interfaces/i_flyout.ts b/packages/blockly/core/interfaces/i_flyout.ts index 067cd5ef2..6906d5857 100644 --- a/packages/blockly/core/interfaces/i_flyout.ts +++ b/packages/blockly/core/interfaces/i_flyout.ts @@ -6,9 +6,7 @@ // Former goog.module ID: Blockly.IFlyout -import type {BlockSvg} from '../block_svg.js'; import type {FlyoutItem} from '../flyout_item.js'; -import type {Coordinate} from '../utils/coordinate.js'; import type {Svg} from '../utils/svg.js'; import type {FlyoutDefinition} from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -129,15 +127,6 @@ export interface IFlyout extends IRegistrable, IFocusableTree { */ getContents(): FlyoutItem[]; - /** - * Create a copy of this block on the workspace. - * - * @param originalBlock The block to copy from the flyout. - * @returns The newly created block. - * @throws {Error} if something went wrong with deserialization. - */ - createBlock(originalBlock: BlockSvg): BlockSvg; - /** Reflow blocks and their mats. */ reflow(): void; @@ -164,27 +153,6 @@ export interface IFlyout extends IRegistrable, IFocusableTree { /** Position the flyout. */ position(): void; - /** - * Determine if a drag delta is toward the workspace, based on the position - * and orientation of the flyout. This is used in determineDragIntention_ to - * determine if a new block should be created or if the flyout should scroll. - * - * @param currentDragDeltaXY How far the pointer has moved from the position - * at mouse down, in pixel units. - * @returns True if the drag is toward the workspace. - */ - isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean; - - /** - * Does this flyout allow you to create a new instance of the given block? - * Used for deciding if a block can be "dragged out of" the flyout. - * - * @param block The block to copy from the flyout. - * @returns True if you can create a new instance of the block, false - * otherwise. - */ - isBlockCreatable(block: BlockSvg): boolean; - /** Scroll the flyout to the beginning of its contents. */ scrollToStart(): void; } diff --git a/packages/blockly/tests/mocha/gesture_test.js b/packages/blockly/tests/mocha/gesture_test.js index 9036141ef..686f4f7aa 100644 --- a/packages/blockly/tests/mocha/gesture_test.js +++ b/packages/blockly/tests/mocha/gesture_test.js @@ -102,9 +102,10 @@ suite('Gesture', function () { test('Clicking on shadow block does not select it', function () { const flyout = this.workspace.getFlyout(true); - flyout.createBlock( + const blockData = Blockly.serialization.blocks.save( flyout.getWorkspace().getBlocksByType('logic_compare')[0], ); + Blockly.serialization.blocks.append(blockData, this.workspace); const block = this.workspace.getBlocksByType('logic_compare')[0]; const shadowBlock = block.getInput('A').connection.targetBlock(); From 09d19d8f7bae9f181852285865a71f9bd7f0f300 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 6 Mar 2026 12:53:18 -0800 Subject: [PATCH 013/200] feat!: Allow using Blockly in web components/shadow DOM (#9611) * feat!: Allow using Blockly in web components/shadow DOM * test: Fix tests * chore: Add a playground to exercise web component support * fix: Remove JSDoc argument * chore: Format playground * fix: Hopefully fix tests in CI * fix: Improve test performance * fix: Fix test failure * fix: Allow changing the theme --- packages/blockly/core/common.ts | 11 +- packages/blockly/core/css.ts | 49 +++--- packages/blockly/core/dropdowndiv.ts | 20 ++- packages/blockly/core/field_input.ts | 11 +- packages/blockly/core/inject.ts | 2 +- .../core/renderers/common/constants.ts | 49 +++--- .../blockly/core/renderers/common/renderer.ts | 1 - .../blockly/core/renderers/zelos/constants.ts | 3 +- packages/blockly/core/tooltip.ts | 35 +++- packages/blockly/core/widgetdiv.ts | 19 +- .../blockly/tests/mocha/contextmenu_test.js | 2 +- .../blockly/tests/mocha/dropdowndiv_test.js | 1 + .../blockly/tests/mocha/navigation_test.js | 4 +- .../blockly/tests/mocha/widget_div_test.js | 1 + .../tests/playgrounds/web_component.html | 166 ++++++++++++++++++ 15 files changed, 307 insertions(+), 67 deletions(-) create mode 100644 packages/blockly/tests/playgrounds/web_component.html diff --git a/packages/blockly/core/common.ts b/packages/blockly/core/common.ts index 7f23779ec..a87e1aa12 100644 --- a/packages/blockly/core/common.ts +++ b/packages/blockly/core/common.ts @@ -141,8 +141,15 @@ let parentContainer: Element | null; * * @returns The parent container. */ -export function getParentContainer(): Element | null { - return parentContainer; +export function getParentContainer( + workspace = getMainWorkspace(), +): Element | null { + if (parentContainer) return parentContainer; + if (workspace && workspace.rendered) { + return (workspace as WorkspaceSvg).getInjectionDiv(); + } + + return null; } /** diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index ab1d494ad..1e796b354 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -6,7 +6,8 @@ // Former goog.module ID: Blockly.Css /** Has CSS already been injected? */ -let injected = false; +const injectionSites = new WeakSet(); +const registeredStyleSheets: Array = []; /** * Add some CSS to the blob that will be injected later. Allows optional @@ -15,10 +16,11 @@ let injected = false; * @param cssContent Multiline CSS string or an array of single lines of CSS. */ export function register(cssContent: string) { - if (injected) { - throw Error('CSS already injected'); - } - content += '\n' + cssContent; + if (typeof window === 'undefined' || !window.CSSStyleSheet) return; + + const sheet = new CSSStyleSheet(); + sheet.replace(cssContent); + registeredStyleSheets.push(sheet); } /** @@ -28,37 +30,40 @@ export function register(cssContent: string) { * b) It speeds up loading by not blocking on a separate HTTP transfer. * c) The CSS content may be made dynamic depending on init options. * + * @param container The div or other HTML element into which Blockly was injected. * @param hasCss If false, don't inject CSS (providing CSS becomes the * document's responsibility). * @param pathToMedia Path from page to the Blockly media directory. */ -export function inject(hasCss: boolean, pathToMedia: string) { +export function inject( + container: HTMLElement, + hasCss: boolean, + pathToMedia: string, +) { + if (!hasCss || typeof window === 'undefined' || !window.CSSStyleSheet) { + return; + } + + const root = container.getRootNode() as Document | ShadowRoot; // Only inject the CSS once. - if (injected) { - return; - } - injected = true; - if (!hasCss) { - return; - } + if (injectionSites.has(root)) return; + injectionSites.add(root); + // Strip off any trailing slash (either Unix or Windows). const mediaPath = pathToMedia.replace(/[\\/]$/, ''); const cssContent = content.replace(/<<>>/g, mediaPath); - // Cleanup the collected css content after injecting it to the DOM. - content = ''; - // Inject CSS tag at start of head. - const cssNode = document.createElement('style'); - cssNode.id = 'blockly-common-style'; - const cssTextNode = document.createTextNode(cssContent); - cssNode.appendChild(cssTextNode); - document.head.insertBefore(cssNode, document.head.firstChild); + const sheet = new CSSStyleSheet(); + sheet.replace(cssContent); + root.adoptedStyleSheets.push(sheet); + + registeredStyleSheets.forEach((sheet) => root.adoptedStyleSheets.push(sheet)); } /** * The CSS content for Blockly. */ -let content = ` +const content = ` :is( .injectionDiv, .blocklyWidgetDiv, diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index ceab467a8..704a767e8 100644 --- a/packages/blockly/core/dropdowndiv.ts +++ b/packages/blockly/core/dropdowndiv.ts @@ -370,6 +370,9 @@ export function show( manageEphemeralFocus: boolean, opt_onHide?: () => void, ): boolean { + const parentDiv = common.getParentContainer(); + parentDiv?.appendChild(div); + owner = newOwner as Field; onHide = opt_onHide || null; // Set direction. @@ -738,10 +741,19 @@ function positionInternal( arrow.style.display = 'none'; } - const initialX = Math.floor(metrics.initialX); - const initialY = Math.floor(metrics.initialY); - const finalX = Math.floor(metrics.finalX); - const finalY = Math.floor(metrics.finalY); + let initialX = Math.floor(metrics.initialX); + let initialY = Math.floor(metrics.initialY); + let finalX = Math.floor(metrics.finalX); + let finalY = Math.floor(metrics.finalY); + + const parentElement = div.parentElement; + if (parentElement) { + const bounds = parentElement.getBoundingClientRect(); + initialX -= bounds.left + window.scrollX; + finalX -= bounds.left + window.scrollX; + initialY -= bounds.top + window.scrollY; + finalY -= bounds.top + window.scrollY; + } // First apply initial translation. div.style.left = initialX + 'px'; diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index 55383a4c1..a8377ae05 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -702,8 +702,15 @@ export abstract class FieldInput extends Field< // In RTL mode block fields and LTR input fields the left edge moves, // whereas the right edge is fixed. Reposition the editor. - const x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left; - const y = bBox.top; + let x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left; + let y = bBox.top; + + const parentElement = div?.parentElement; + if (parentElement) { + const bounds = parentElement.getBoundingClientRect(); + x -= bounds.left + window.scrollX; + y -= bounds.top + window.scrollY; + } div!.style.left = `${x}px`; div!.style.top = `${y}px`; diff --git a/packages/blockly/core/inject.ts b/packages/blockly/core/inject.ts index 1ecefa7c4..ca62eb47f 100644 --- a/packages/blockly/core/inject.ts +++ b/packages/blockly/core/inject.ts @@ -95,7 +95,7 @@ function createDom(container: HTMLElement, options: Options): SVGElement { container.setAttribute('dir', 'LTR'); // Load CSS. - Css.inject(options.hasCss, options.pathToMedia); + Css.inject(container, options.hasCss, options.pathToMedia); // Build the SVG DOM. /* diff --git a/packages/blockly/core/renderers/common/constants.ts b/packages/blockly/core/renderers/common/constants.ts index c5a7a759c..764cef029 100644 --- a/packages/blockly/core/renderers/common/constants.ts +++ b/packages/blockly/core/renderers/common/constants.ts @@ -116,6 +116,8 @@ export function isNotch(shape: Shape): shape is Notch { ); } +const injectionSites = new Map>(); + /** * An object that provides constants for rendering blocks. */ @@ -327,9 +329,6 @@ export class ConstantProvider { */ private debugFilter: SVGElement | null = null; - /** The + + + + + + + +
+
+
Light DOM
+
+
+
+
+ +
+
+ Shadow DOM via <blockly-editor> +
+
+ +
+
+
+ + From 25968ffbdf52e0566d6e05cffe0bba693ff4d8da Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 9 Mar 2026 13:28:34 -0700 Subject: [PATCH 014/200] feat: Add keyboard shortcut to focus the workspace (#9615) * feat: Add keyboard shortcut to focus the workspace * test: Added tests for keyboard shortcut to focus workspace * fix: Disable the focus workspace shortcut while dragging --- packages/blockly/core/shortcut_items.ts | 60 ++++++++++++++--- .../tests/mocha/shortcut_items_test.js | 64 ++++++++++++++++++- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 852521549..e2ec18747 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -38,6 +38,14 @@ export enum names { UNDO = 'undo', REDO = 'redo', MENU = 'menu', + FOCUS_WORKSPACE = 'focus_workspace', + START_MOVE = 'start_move', + FINISH_MOVE = 'finish_move', + ABORT_MOVE = 'abort_move', + MOVE_UP = 'move_up', + MOVE_DOWN = 'move_down', + MOVE_LEFT = 'move_left', + MOVE_RIGHT = 'move_right', } /** @@ -391,7 +399,7 @@ export function registerMovementShortcuts() { const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [ { - name: 'start_move', + name: names.START_MOVE, preconditionFn: (workspace) => { const startDraggable = getCurrentDraggable(workspace); return !!startDraggable && KeyboardMover.mover.canMove(startDraggable); @@ -412,7 +420,7 @@ export function registerMovementShortcuts() { keyCodes: [KeyCodes.M], }, { - name: 'finish_move', + name: names.FINISH_MOVE, preconditionFn: () => KeyboardMover.mover.isMoving(), callback: (_workspace, e) => KeyboardMover.mover.finishMove(e as KeyboardEvent), @@ -420,7 +428,7 @@ export function registerMovementShortcuts() { allowCollision: true, }, { - name: 'abort_move', + name: names.ABORT_MOVE, preconditionFn: () => KeyboardMover.mover.isMoving(), callback: (_workspace, e) => KeyboardMover.mover.abortMove(e as KeyboardEvent), @@ -428,7 +436,7 @@ export function registerMovementShortcuts() { allowCollision: true, }, { - name: 'move_left', + name: names.MOVE_LEFT, preconditionFn: () => KeyboardMover.mover.isMoving(), callback: (_workspace, e) => { e.preventDefault(); @@ -443,7 +451,7 @@ export function registerMovementShortcuts() { allowCollision: true, }, { - name: 'move_right', + name: names.MOVE_RIGHT, preconditionFn: () => KeyboardMover.mover.isMoving(), callback: (_workspace, e) => { e.preventDefault(); @@ -458,7 +466,7 @@ export function registerMovementShortcuts() { allowCollision: true, }, { - name: 'move_up', + name: names.MOVE_UP, preconditionFn: () => KeyboardMover.mover.isMoving(), callback: (_workspace, e) => { e.preventDefault(); @@ -473,7 +481,7 @@ export function registerMovementShortcuts() { allowCollision: true, }, { - name: 'move_down', + name: names.MOVE_DOWN, preconditionFn: () => KeyboardMover.mover.isMoving(), callback: (_workspace, e) => { e.preventDefault(); @@ -508,7 +516,7 @@ export function registerShowContextMenu() { preconditionFn: (workspace) => { return !workspace.isDragging(); }, - callback: (workspace, e) => { + callback: (_workspace, e) => { const target = getFocusManager().getFocusedNode(); if (hasContextMenu(target)) { target.showContextMenu(e); @@ -523,6 +531,33 @@ export function registerShowContextMenu() { ShortcutRegistry.registry.register(contextMenuShortcut); } +/** + * Registers keyboard shortcut to focus the workspace. + */ +export function registerFocusWorkspace() { + const resolveWorkspace = (workspace: WorkspaceSvg) => { + if (workspace.isFlyout) { + const target = workspace.targetWorkspace; + if (target) { + return resolveWorkspace(target); + } + } + return workspace.getRootWorkspace() ?? workspace; + }; + + const contextMenuShortcut: KeyboardShortcut = { + name: names.FOCUS_WORKSPACE, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + getFocusManager().focusNode(resolveWorkspace(workspace)); + return true; + }, + keyCodes: [KeyCodes.W], + }; + ShortcutRegistry.registry.register(contextMenuShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -537,8 +572,17 @@ export function registerDefaultShortcuts() { registerPaste(); registerUndo(); registerRedo(); +} + +/** + * Registers an extended set of keyboard shortcuts used to support deep keyboard + * navigation of Blockly. + */ +export function registerKeyboardNavigationShortcuts() { registerShowContextMenu(); registerMovementShortcuts(); + registerFocusWorkspace(); } registerDefaultShortcuts(); +registerKeyboardNavigationShortcuts(); diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 99c7bd4d0..608a65171 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -16,7 +16,8 @@ import {createKeyDownEvent} from './test_helpers/user_input.js'; suite('Keyboard Shortcut Items', function () { setup(function () { sharedTestSetup.call(this); - this.workspace = Blockly.inject('blocklyDiv', {}); + const toolbox = document.getElementById('toolbox-categories'); + this.workspace = Blockly.inject('blocklyDiv', {toolbox}); this.injectionDiv = this.workspace.getInjectionDiv(); Blockly.ContextMenuRegistry.registry.reset(); Blockly.ContextMenuItems.registerDefaultOptions(); @@ -486,4 +487,65 @@ suite('Keyboard Shortcut Items', function () { ); }); }); + + suite('Focus Workspace (W)', function () { + setup(function () { + this.testFocusChange = (startingElement) => { + Blockly.getFocusManager().focusNode(startingElement); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + startingElement, + ); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.W); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.workspace, + ); + }; + }); + + test('Does not change focus when workspace is already focused', function () { + this.testFocusChange(this.workspace); + }); + + test('Focuses workspace when toolbox is focused', function () { + this.testFocusChange(this.workspace.getToolbox()); + }); + + test('Focuses workspace when flyout is focused', function () { + this.workspace.getToolbox().getFlyout().show(); + const flyoutWorkspace = this.workspace + .getToolbox() + .getFlyout() + .getWorkspace(); + this.testFocusChange(flyoutWorkspace); + }); + + test('Focuses workspace when a block is focused', function () { + const block = this.workspace.newBlock('controls_if'); + this.testFocusChange(block); + }); + + suite('With mutator', function () { + test('Focuses root workspace when a mutator block is focused', async function () { + const block = this.workspace.newBlock('controls_if'); + const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + const mutatorWorkspace = icon.getWorkspace(); + this.testFocusChange(mutatorWorkspace.getAllBlocks()[0]); + }); + + test("Focuses workspace when a mutator's flyout is focused", async function () { + const block = this.workspace.newBlock('controls_if'); + const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + const mutatorFlyoutWorkspace = icon + .getWorkspace() + .getFlyout() + .getWorkspace(); + this.testFocusChange(mutatorFlyoutWorkspace); + }); + }); + }); }); From 3044298f990b24e3b4d41968aabd44e5e6e070bb Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:19:59 -0400 Subject: [PATCH 015/200] feat: Move mode for stacks of blocks (#9630) * feat: Move mode for stacks of blocks * lint; add tests * push to remote in order to switch devices (tests still failing) * fix tests * code review test updates --- .../core/dragging/block_drag_strategy.ts | 16 ++- .../core/keyboard_nav/keyboard_mover.ts | 1 - packages/blockly/core/shortcut_items.ts | 49 +++++---- .../tests/mocha/keyboard_movement_test.js | 104 ++++++++++++++++++ 4 files changed, 146 insertions(+), 24 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index cabf7beae..39dff646e 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -256,14 +256,22 @@ export class BlockDragStrategy implements IDragStrategy { /** * Get whether the drag should act on a single block or a block stack. * - * @param e The instigating pointer event, if any. + * @param e The instigating pointer or keyboard event, if any. * @returns True if just the initial block should be dragged out, false * if all following blocks should also be dragged. */ protected shouldHealStack(e: PointerEvent | KeyboardEvent | undefined) { - return e instanceof PointerEvent - ? e.ctrlKey || e.metaKey - : !!this.block.previousConnection; + if (e instanceof PointerEvent) { + // For pointer events, we drag the whole stack unless a modifier key + // was also pressed. + return e.ctrlKey || e.metaKey; + } else if (e instanceof KeyboardEvent) { + // For keyboard events, we drag the single focused block, unless the + // shift key is pressed or the block has no previous connection. + return !(e.shiftKey || !this.block.previousConnection); + } else { + return false; + } } /** diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index f3c9ecee0..ea2aefc36 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -3,7 +3,6 @@ * Copyright 2026 Raspberry Pi Foundation * SPDX-License-Identifier: Apache-2.0 */ - import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IDragger} from '../interfaces/i_dragger.js'; import * as registry from '../registry.js'; diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index e2ec18747..de13f0788 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -40,6 +40,7 @@ export enum names { MENU = 'menu', FOCUS_WORKSPACE = 'focus_workspace', START_MOVE = 'start_move', + START_MOVE_STACK = 'start_move_stack', FINISH_MOVE = 'finish_move', ABORT_MOVE = 'abort_move', MOVE_UP = 'move_up', @@ -397,27 +398,37 @@ export function registerMovementShortcuts() { return workspace.getCursor().getSourceBlock() ?? undefined; }; + const shiftM = ShortcutRegistry.registry.createSerializedKey(KeyCodes.M, [ + KeyCodes.SHIFT, + ]); + + const startMoveShortcut: KeyboardShortcut = { + name: names.START_MOVE, + preconditionFn: (workspace) => { + const startDraggable = getCurrentDraggable(workspace); + return !!startDraggable && KeyboardMover.mover.canMove(startDraggable); + }, + callback: (workspace, e) => { + keyboardNavigationController.setIsActive(true); + const startDraggable = getCurrentDraggable(workspace); + // Focus the root draggable in case one of its children + // was focused when the move was triggered. + if (startDraggable) { + getFocusManager().focusNode(startDraggable); + } + return ( + !!startDraggable && + KeyboardMover.mover.startMove(startDraggable, e as KeyboardEvent) + ); + }, + keyCodes: [KeyCodes.M], + }; const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [ + startMoveShortcut, { - name: names.START_MOVE, - preconditionFn: (workspace) => { - const startDraggable = getCurrentDraggable(workspace); - return !!startDraggable && KeyboardMover.mover.canMove(startDraggable); - }, - callback: (workspace, e) => { - keyboardNavigationController.setIsActive(true); - const startDraggable = getCurrentDraggable(workspace); - // Focus the root draggable in case one of its children - // was focused when the move was triggered. - if (startDraggable) { - getFocusManager().focusNode(startDraggable); - } - return ( - !!startDraggable && - KeyboardMover.mover.startMove(startDraggable, e as KeyboardEvent) - ); - }, - keyCodes: [KeyCodes.M], + ...startMoveShortcut, + name: names.START_MOVE_STACK, + keyCodes: [shiftM], }, { name: names.FINISH_MOVE, diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index b4ad43d71..9d6ac0cdd 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -35,6 +35,13 @@ suite('Keyboard-driven movement', function () { workspace.getInjectionDiv().dispatchEvent(event); } + function startMoveStack(workspace) { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.M, [ + Blockly.utils.KeyCodes.SHIFT, + ]); + workspace.getInjectionDiv().dispatchEvent(event); + } + function moveUp(workspace, modifiers) { const event = createKeyDownEvent(Blockly.utils.KeyCodes.UP, modifiers); workspace.getInjectionDiv().dispatchEvent(event); @@ -407,6 +414,103 @@ suite('Keyboard-driven movement', function () { testExemptedShortcutsAllowed(); }); + suite('to disconnect blocks', function () { + setup(function () { + this.block1 = this.workspace.newBlock('draw_emoji'); + this.block1.initSvg(); + this.block1.render(); + + this.block2 = this.workspace.newBlock('draw_emoji'); + this.block2.initSvg(); + this.block2.render(); + this.block1.nextConnection.connect(this.block2.previousConnection); + + this.block3 = this.workspace.newBlock('draw_emoji'); + this.block3.initSvg(); + this.block3.render(); + this.block2.nextConnection.connect(this.block3.previousConnection); + }); + + test('from top block - Detaches single block', function () { + Blockly.getFocusManager().focusNode(this.block1); + startMove(this.workspace); + assert.isNull(this.block1.nextConnection.targetBlock()); + assert.equal(this.block1.isDragging(), true); + assert.equal(this.block2.isDragging(), false); + assert.equal(this.block3.isDragging(), false); + cancelMove(this.workspace); + }); + + test('from middle block - Detaches single block', function () { + Blockly.getFocusManager().focusNode(this.block2); + startMove(this.workspace); + assert.isNull(this.block2.previousConnection.targetBlock()); + assert.isNull(this.block2.nextConnection.targetBlock()); + assert.equal(this.block1.isDragging(), false); + assert.equal(this.block2.isDragging(), true); + assert.equal(this.block3.isDragging(), false); + cancelMove(this.workspace); + }); + + test('from bottom block - Detaches single block', function () { + Blockly.getFocusManager().focusNode(this.block3); + startMove(this.workspace); + assert.isNull(this.block3.previousConnection.targetBlock()); + assert.equal(this.block1.isDragging(), false); + assert.equal(this.block2.isDragging(), false); + assert.equal(this.block3.isDragging(), true); + cancelMove(this.workspace); + }); + + test('from top block - Detaches entire three-block stack', function () { + Blockly.getFocusManager().focusNode(this.block1); + startMoveStack(this.workspace); + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + assert.equal(this.block1.isDragging(), true); + assert.equal(this.block2.isDragging(), true); + assert.equal(this.block3.isDragging(), true); + cancelMove(this.workspace); + }); + + test('from middle block - Detaches two-block stack from middle down', function () { + Blockly.getFocusManager().focusNode(this.block2); + startMoveStack(this.workspace); + assert.isNull(this.block2.previousConnection.targetBlock()); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + assert.equal(this.block1.isDragging(), false); + assert.equal(this.block2.isDragging(), true); + assert.equal(this.block3.isDragging(), true); + cancelMove(this.workspace); + }); + + test('from bottom block - Detaches single-block stack from bottom', function () { + Blockly.getFocusManager().focusNode(this.block3); + startMoveStack(this.workspace); + assert.isNull(this.block3.previousConnection.targetBlock()); + assert.equal(this.block1.isDragging(), false); + assert.equal(this.block2.isDragging(), false); + assert.equal(this.block3.isDragging(), true); + cancelMove(this.workspace); + }); + + test('Cancel move restores connections', function () { + Blockly.getFocusManager().focusNode(this.block2); + startMove(this.workspace); + cancelMove(this.workspace); + // Original stack restored + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + + Blockly.getFocusManager().focusNode(this.block2); + startMoveStack(this.workspace); + cancelMove(this.workspace); + // Original stack restored + assert.strictEqual(this.block1.nextConnection.targetBlock(), this.block2); + assert.strictEqual(this.block2.nextConnection.targetBlock(), this.block3); + }); + }); + suite('of blocks', function () { setup(function () { this.element = this.workspace.newBlock('logic_boolean'); From 83c8caca97361928e2cd47df1f7fd844a7ec65ff Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 17 Mar 2026 10:22:27 -0700 Subject: [PATCH 016/200] chore: Make JSDom a peer/dev dependency on NodeJS only (#9579) * chore: Make JSDom a peer/dev dependency on NodeJS only * chore: Bump closure compiler version --- package-lock.json | 1473 +++++++++++++++++++++------------ packages/blockly/package.json | 12 +- 2 files changed, 935 insertions(+), 550 deletions(-) diff --git a/package-lock.json b/package-lock.json index ce9b99b05..dacc5c768 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,14 +16,74 @@ "@commitlint/config-conventional": "^20.0.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -31,10 +91,13 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.27.1", "dev": true, "license": "MIT", "engines": { @@ -42,17 +105,15 @@ } }, "node_modules/@commitlint/cli": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.2.tgz", - "integrity": "sha512-YjYSX2yj/WsVoxh9mNiymfFS2ADbg2EK4+1WAsMuckwKMCqJ5PDG0CJU/8GvmHWcv4VRB2V02KqSiecRksWqZQ==", + "version": "20.1.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^20.4.0", - "@commitlint/lint": "^20.4.2", - "@commitlint/load": "^20.4.0", - "@commitlint/read": "^20.4.0", - "@commitlint/types": "^20.4.0", + "@commitlint/format": "^20.0.0", + "@commitlint/lint": "^20.0.0", + "@commitlint/load": "^20.1.0", + "@commitlint/read": "^20.0.0", + "@commitlint/types": "^20.0.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, @@ -64,27 +125,23 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.2.tgz", - "integrity": "sha512-rwkTF55q7Q+6dpSKUmJoScV0f3EpDlWKw2UPzklkLS4o5krMN1tPWAVOgHRtyUTMneIapLeQwaCjn44Td6OzBQ==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", - "conventional-changelog-conventionalcommits": "^9.1.0" + "@commitlint/types": "^20.0.0", + "conventional-changelog-conventionalcommits": "^7.0.2" }, "engines": { "node": ">=v18" } }, "node_modules/@commitlint/config-validator": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.4.0.tgz", - "integrity": "sha512-zShmKTF+sqyNOfAE0vKcqnpvVpG0YX8F9G/ZIQHI2CoKyK+PSdladXMSns400aZ5/QZs+0fN75B//3Q5CHw++w==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", + "@commitlint/types": "^20.0.0", "ajv": "^8.11.0" }, "engines": { @@ -92,13 +149,11 @@ } }, "node_modules/@commitlint/ensure": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.4.1.tgz", - "integrity": "sha512-WLQqaFx1pBooiVvBrA1YfJNFqZF8wS/YGOtr5RzApDbV9tQ52qT5VkTsY65hFTnXhW8PcDfZLaknfJTmPejmlw==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", + "@commitlint/types": "^20.0.0", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "lodash.snakecase": "^4.1.1", @@ -111,8 +166,6 @@ }, "node_modules/@commitlint/execute-rule": { "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", - "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", "dev": true, "license": "MIT", "engines": { @@ -120,74 +173,98 @@ } }, "node_modules/@commitlint/format": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.4.0.tgz", - "integrity": "sha512-i3ki3WR0rgolFVX6r64poBHXM1t8qlFel1G1eCBvVgntE3fCJitmzSvH5JD/KVJN/snz6TfaX2CLdON7+s4WVQ==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", - "picocolors": "^1.1.1" + "@commitlint/types": "^20.0.0", + "chalk": "^5.3.0" }, "engines": { "node": ">=v18" } }, + "node_modules/@commitlint/format/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@commitlint/is-ignored": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.4.1.tgz", - "integrity": "sha512-In5EO4JR1lNsAv1oOBBO24V9ND1IqdAJDKZiEpdfjDl2HMasAcT7oA+5BKONv1pRoLG380DGPE2W2RIcUwdgLA==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", + "@commitlint/types": "^20.0.0", "semver": "^7.6.0" }, "engines": { "node": ">=v18" } }, + "node_modules/@commitlint/is-ignored/node_modules/semver": { + "version": "7.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@commitlint/lint": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.2.tgz", - "integrity": "sha512-buquzNRtFng6xjXvBU1abY/WPEEjCgUipNQrNmIWe8QuJ6LWLtei/LDBAzEe5ASm45+Q9L2Xi3/GVvlj50GAug==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^20.4.1", - "@commitlint/parse": "^20.4.1", - "@commitlint/rules": "^20.4.2", - "@commitlint/types": "^20.4.0" + "@commitlint/is-ignored": "^20.0.0", + "@commitlint/parse": "^20.0.0", + "@commitlint/rules": "^20.0.0", + "@commitlint/types": "^20.0.0" }, "engines": { "node": ">=v18" } }, "node_modules/@commitlint/load": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.4.0.tgz", - "integrity": "sha512-Dauup/GfjwffBXRJUdlX/YRKfSVXsXZLnINXKz0VZkXdKDcaEILAi9oflHGbfydonJnJAbXEbF3nXPm9rm3G6A==", + "version": "20.1.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.4.0", + "@commitlint/config-validator": "^20.0.0", "@commitlint/execute-rule": "^20.0.0", - "@commitlint/resolve-extends": "^20.4.0", - "@commitlint/types": "^20.4.0", + "@commitlint/resolve-extends": "^20.1.0", + "@commitlint/types": "^20.0.0", + "chalk": "^5.3.0", "cosmiconfig": "^9.0.0", "cosmiconfig-typescript-loader": "^6.1.0", - "is-plain-obj": "^4.1.0", - "lodash.mergewith": "^4.6.2", - "picocolors": "^1.1.1" + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" }, "engines": { "node": ">=v18" } }, + "node_modules/@commitlint/load/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@commitlint/message": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.0.tgz", - "integrity": "sha512-B5lGtvHgiLAIsK5nLINzVW0bN5hXv+EW35sKhYHE8F7V9Uz1fR4tx3wt7mobA5UNhZKUNgB/+ldVMQE6IHZRyA==", + "version": "20.0.0", "dev": true, "license": "MIT", "engines": { @@ -195,29 +272,25 @@ } }, "node_modules/@commitlint/parse": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.4.1.tgz", - "integrity": "sha512-XNtZjeRcFuAfUnhYrCY02+mpxwY4OmnvD3ETbVPs25xJFFz1nRo/25nHj+5eM+zTeRFvWFwD4GXWU2JEtoK1/w==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.0", - "conventional-changelog-angular": "^8.1.0", - "conventional-commits-parser": "^6.2.1" + "@commitlint/types": "^20.0.0", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" }, "engines": { "node": ">=v18" } }, "node_modules/@commitlint/read": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.4.0.tgz", - "integrity": "sha512-QfpFn6/I240ySEGv7YWqho4vxqtPpx40FS7kZZDjUJ+eHxu3azfhy7fFb5XzfTqVNp1hNoI3tEmiEPbDB44+cg==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/top-level": "^20.4.0", - "@commitlint/types": "^20.4.0", + "@commitlint/top-level": "^20.0.0", + "@commitlint/types": "^20.0.0", "git-raw-commits": "^4.0.0", "minimist": "^1.2.8", "tinyexec": "^1.0.0" @@ -227,14 +300,12 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.4.0.tgz", - "integrity": "sha512-ay1KM8q0t+/OnlpqXJ+7gEFQNlUtSU5Gxr8GEwnVf2TPN3+ywc5DzL3JCxmpucqxfHBTFwfRMXxPRRnR5Ki20g==", + "version": "20.1.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.4.0", - "@commitlint/types": "^20.4.0", + "@commitlint/config-validator": "^20.0.0", + "@commitlint/types": "^20.0.0", "global-directory": "^4.0.1", "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", @@ -244,17 +315,23 @@ "node": ">=v18" } }, + "node_modules/@commitlint/resolve-extends/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@commitlint/rules": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.2.tgz", - "integrity": "sha512-oz83pnp5Yq6uwwTAabuVQPNlPfeD2Y5ZjMb7Wx8FSUlu4sLYJjbBWt8031Z0osCFPfHzAwSYrjnfDFKtuSMdKg==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^20.4.1", - "@commitlint/message": "^20.4.0", + "@commitlint/ensure": "^20.0.0", + "@commitlint/message": "^20.0.0", "@commitlint/to-lines": "^20.0.0", - "@commitlint/types": "^20.4.0" + "@commitlint/types": "^20.0.0" }, "engines": { "node": ">=v18" @@ -262,8 +339,6 @@ }, "node_modules/@commitlint/to-lines": { "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", - "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", "dev": true, "license": "MIT", "engines": { @@ -271,32 +346,274 @@ } }, "node_modules/@commitlint/top-level": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.0.tgz", - "integrity": "sha512-NDzq8Q6jmFaIIBC/GG6n1OQEaHdmaAAYdrZRlMgW6glYWGZ+IeuXmiymDvQNXPc82mVxq2KiE3RVpcs+1OeDeA==", + "version": "20.0.0", "dev": true, "license": "MIT", "dependencies": { - "escalade": "^3.2.0" + "find-up": "^7.0.0" }, "engines": { "node": ">=v18" } }, - "node_modules/@commitlint/types": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.4.0.tgz", - "integrity": "sha512-aO5l99BQJ0X34ft8b0h7QFkQlqxC6e7ZPVmBKz13xM9O8obDaM1Cld4sQlJDXXU/VFuUzQ30mVtHjVz74TuStw==", + "node_modules/@commitlint/top-level/node_modules/find-up": { + "version": "7.0.0", "dev": true, "license": "MIT", "dependencies": { - "conventional-commits-parser": "^6.2.1", - "picocolors": "^1.1.1" + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/locate-path": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/p-limit": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/p-locate": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/path-exists": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@commitlint/top-level/node_modules/yocto-queue": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/types": { + "version": "20.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" }, "engines": { "node": ">=v18" } }, + "node_modules/@commitlint/types/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "dev": true, @@ -316,17 +633,28 @@ "node": "20 || >=22" } }, - "node_modules/@simple-libs/stream-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", - "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.21", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://ko-fi.com/dangreen" + "node": ">= 14" } }, "node_modules/ajv": { @@ -373,8 +701,6 @@ }, "node_modules/array-ify": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", "dev": true, "license": "MIT" }, @@ -404,6 +730,16 @@ } } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/blockly": { "resolved": "packages/blockly", "link": true @@ -418,8 +754,6 @@ }, "node_modules/chalk": { "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", "engines": { @@ -468,8 +802,6 @@ }, "node_modules/compare-func": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", "dev": true, "license": "MIT", "dependencies": { @@ -478,52 +810,46 @@ } }, "node_modules/conventional-changelog-angular": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.2.0.tgz", - "integrity": "sha512-4YB1zEXqB17oBI8yRsAs1T+ZhbdsOgJqkl6Trz+GXt/eKf1e4jnA0oW+sOd9BEENzEViuNW0DNoFFjSf3CeC5Q==", + "version": "7.0.0", "dev": true, "license": "ISC", "dependencies": { "compare-func": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=16" } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.2.0.tgz", - "integrity": "sha512-fCf+ODjseueTV09wVBoC0HXLi3OyuBJ+HfE3L63Khxqnr99f9nUcnQh3a15lCWHlGLihyZShW/mVVkBagr9JvQ==", + "version": "7.0.2", "dev": true, "license": "ISC", "dependencies": { "compare-func": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=16" } }, "node_modules/conventional-commits-parser": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz", - "integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==", + "version": "5.0.0", "dev": true, "license": "MIT", "dependencies": { - "@simple-libs/stream-utils": "^1.2.0", - "meow": "^13.0.0" + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" }, "bin": { - "conventional-commits-parser": "dist/cli/index.js" + "conventional-commits-parser": "cli.mjs" }, "engines": { - "node": ">=18" + "node": ">=16" } }, "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.0", "dev": true, "license": "MIT", "dependencies": { @@ -548,13 +874,11 @@ } }, "node_modules/cosmiconfig-typescript-loader": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.2.0.tgz", - "integrity": "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==", + "version": "6.1.0", "dev": true, "license": "MIT", "dependencies": { - "jiti": "^2.6.1" + "jiti": "^2.4.1" }, "engines": { "node": ">=v18" @@ -565,10 +889,38 @@ "typescript": ">=5" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/dargs": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", - "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", "dev": true, "license": "MIT", "engines": { @@ -578,10 +930,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dot-prop": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -598,8 +995,6 @@ }, "node_modules/env-paths": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", "engines": { @@ -608,8 +1003,6 @@ }, "node_modules/error-ex": { "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -667,9 +1060,6 @@ }, "node_modules/git-raw-commits": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", - "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", - "deprecated": "This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.", "dev": true, "license": "MIT", "dependencies": { @@ -684,19 +1074,6 @@ "node": ">=16" } }, - "node_modules/git-raw-commits/node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "13.0.0", "dev": true, @@ -715,8 +1092,6 @@ }, "node_modules/global-directory": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -729,15 +1104,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/google-closure-compiler": { - "version": "20260225.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20260225.0.0.tgz", - "integrity": "sha512-qUu5r4DNRUJVd1FOkigJxfK1XJWfJdUs+AWc5ArehYavgCFBJcVsa5C9spqrJEua7+xtBSXuC2J2ggS5fEpaKg==", + "version": "20260315.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20260315.0.0.tgz", + "integrity": "sha512-z+Zdkth5+bdt+bSy3HuYRgjSAgx4WncBZ0Rd+/1Hf3wFemkkTxXGXpG7A5Y8n5WrTsPd1n/fxVuD5xfFL6s5Dw==", "dev": true, "license": "Apache-2.0", "dependencies": { "chalk": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 <5.6.1 || ^5.6.2 >5.6.1", - "google-closure-compiler-java": "^20260225.0.0", + "google-closure-compiler-java": "^20260315.0.0", "minimist": "^1.0.0", "vinyl": "^3.0.1", "vinyl-sourcemaps-apply": "^0.2.0" @@ -749,23 +1132,23 @@ "node": ">=18" }, "optionalDependencies": { - "google-closure-compiler-linux": "^20260225.0.0", - "google-closure-compiler-linux-arm64": "^20260225.0.0", - "google-closure-compiler-macos": "^20260225.0.0", - "google-closure-compiler-windows": "^20260225.0.0" + "google-closure-compiler-linux": "^20260315.0.0", + "google-closure-compiler-linux-arm64": "^20260315.0.0", + "google-closure-compiler-macos": "^20260315.0.0", + "google-closure-compiler-windows": "^20260315.0.0" } }, "node_modules/google-closure-compiler-java": { - "version": "20260225.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20260225.0.0.tgz", - "integrity": "sha512-sxxHBpF0ph878cjozdqpEBgR/rv7fN5aYgDgNXcHdNWPs5Qr5uQaV3rdViqi/vMpqC+CYMft7tuY6375IfkfOg==", + "version": "20260315.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20260315.0.0.tgz", + "integrity": "sha512-3CYSN3x7S7xCc/7Yx9vmH4pOc/8tkJdPjftUV1tUt2/tYKYEeH9mGv5dtrs22Uf6qXdIqlEBGg+ZQXX13xVpww==", "dev": true, "license": "Apache-2.0" }, "node_modules/google-closure-compiler-linux": { - "version": "20260225.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20260225.0.0.tgz", - "integrity": "sha512-8rPCjLuqvbFUL6Pk+lrrhj9BE9jFFymx2bMHJjQQtuT0NYHBBdy6zm7tN++zp3zPOOUupBizNsWLj2pkkP24Ag==", + "version": "20260315.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20260315.0.0.tgz", + "integrity": "sha512-jXUnyY3Cr5kmBDmGuxw3HULjzX69AeXVRcIpI+YuFGfo8qX1dIbkOpNK7JHWtJ7qE01foGmCwjnx5WOSrplYyg==", "cpu": [ "x32", "x64" @@ -778,9 +1161,9 @@ ] }, "node_modules/google-closure-compiler-linux-arm64": { - "version": "20260225.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20260225.0.0.tgz", - "integrity": "sha512-L7/kXqh5wmpnU44ierzHMevpaQgZHTIwp9LPPJLAvUWJ/K54HUGcz4sUhCbUzAbXE96YzQ+q87dG9ueBkZqffA==", + "version": "20260315.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20260315.0.0.tgz", + "integrity": "sha512-e0dSgFEg/bE9gscw+u2Roy/FkJOQ/6MvG3nBCPR8IqAkJ+ibBAQKnYOSK1sAUp1bXJROiUJy6a+GTJ312FKS+A==", "cpu": [ "arm64" ], @@ -792,9 +1175,9 @@ ] }, "node_modules/google-closure-compiler-macos": { - "version": "20260225.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20260225.0.0.tgz", - "integrity": "sha512-QTxlYCTbyE80OyiNmxf0JAxiKKNECIQ1jINTG6ezKpBefERvoLkezhsjlfxm/KXE6YwCWXd6+puZ9vxnoWUMqQ==", + "version": "20260315.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20260315.0.0.tgz", + "integrity": "sha512-yhY46Mdbqs7HAPsYAhXlt9Q2rROLZfaZrTFZZqyVo/uufEmGH3vOyxshTbBQWhaJQHkELWsc/XKSVfvX4rcoYA==", "cpu": [ "arm64" ], @@ -806,9 +1189,9 @@ ] }, "node_modules/google-closure-compiler-windows": { - "version": "20260225.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20260225.0.0.tgz", - "integrity": "sha512-tDpH19bEFqo7R+GxDWvvpeBg/HfPpCohCNG6a0Jc9im40zfVKa0YwJxuStXk0Tr4T9mIg7OnnEfVla5aeJkLzg==", + "version": "20260315.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20260315.0.0.tgz", + "integrity": "sha512-EpIPPU6vQ5EzuzKQMe3vhEWLqqXY4SKNoLjXrr4GWXoses9h7xT6nkHDuDe/7x5MWZOMRv4qmlW3PzLCsx98qg==", "cpu": [ "x32", "x64" @@ -820,6 +1203,30 @@ "win32" ] }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "dev": true, @@ -852,20 +1259,8 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, "license": "MIT" }, @@ -879,8 +1274,6 @@ }, "node_modules/is-obj": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true, "license": "MIT", "engines": { @@ -898,10 +1291,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", "bin": { @@ -910,8 +1317,6 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, "license": "MIT" }, @@ -926,10 +1331,87 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, "license": "MIT" }, @@ -938,52 +1420,76 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true, "license": "MIT" }, "node_modules/lodash.camelcase": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", "dev": true, "license": "MIT" }, "node_modules/lodash.kebabcase": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", "dev": true, "license": "MIT" }, "node_modules/lodash.mergewith": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "dev": true, "license": "MIT" }, "node_modules/lodash.snakecase": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "dev": true, "license": "MIT" }, "node_modules/lodash.startcase": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", "dev": true, "license": "MIT" }, "node_modules/lodash.upperfirst": { "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", "dev": true, "license": "MIT" }, @@ -995,14 +1501,19 @@ "node": "20 || >=22" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "version": "12.1.1", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=16.10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1038,6 +1549,11 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "dev": true, @@ -1056,8 +1572,6 @@ }, "node_modules/parse-json": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -1088,12 +1602,13 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/punycode": { + "version": "2.3.1", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "node": ">=6" + } }, "node_modules/remove-trailing-separator": { "version": "1.1.0", @@ -1124,16 +1639,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/rimraf": { "version": "6.1.2", "dev": true, @@ -1152,17 +1657,15 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/saxes": { + "version": "6.0.0", "dev": true, "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "dependencies": { + "xmlchars": "^2.2.0" }, "engines": { - "node": ">=10" + "node": ">=v12.22.7" } }, "node_modules/source-map": { @@ -1173,6 +1676,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "dev": true, @@ -1215,6 +1728,11 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "dev": true, + "license": "MIT" + }, "node_modules/teex": { "version": "1.0.1", "dev": true, @@ -1231,20 +1749,91 @@ "b4a": "^1.6.4" } }, + "node_modules/text-extensions": { + "version": "2.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.26" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", "dev": true, "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/vinyl": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", - "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "license": "MIT", "dependencies": { @@ -1265,6 +1854,49 @@ "source-map": "^0.5.1" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "dev": true, @@ -1281,6 +1913,39 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "dev": true, @@ -1318,9 +1983,6 @@ "version": "12.4.1", "hasInstallScript": true, "license": "Apache-2.0", - "dependencies": { - "jsdom": "26.1.0" - }, "devDependencies": { "@blockly/block-test": "^7.0.2", "@blockly/dev-tools": "^9.0.2", @@ -1342,7 +2004,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260225.0.0", + "google-closure-compiler": "^20260315.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", @@ -1355,6 +2017,7 @@ "gulp-sourcemaps": "^3.0.0", "gulp-umd": "^2.0.0", "http-server": "^14.0.0", + "jsdom": "27.4.0", "json5": "^2.2.0", "markdown-tables-to-json": "^0.1.7", "mocha": "^11.3.0", @@ -1371,6 +2034,9 @@ }, "engines": { "node": ">=22" + }, + "peerDependencies": { + "jsdom": "^27.4.0" } }, "packages/blockly/node_modules/@aashutoshrathi/word-wrap": { @@ -1381,21 +2047,6 @@ "node": ">=0.10.0" } }, - "packages/blockly/node_modules/@asamuzakjp/css-color": { - "version": "3.1.1", - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.2", - "@csstools/css-color-parser": "^3.0.8", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "packages/blockly/node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "license": "ISC" - }, "packages/blockly/node_modules/@blockly/block-test": { "version": "7.0.2", "dev": true, @@ -1557,106 +2208,6 @@ "blockly": "^12.0.0" } }, - "packages/blockly/node_modules/@csstools/color-helpers": { - "version": "5.0.2", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "packages/blockly/node_modules/@csstools/css-calc": { - "version": "2.1.2", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" - } - }, - "packages/blockly/node_modules/@csstools/css-color-parser": { - "version": "3.0.8", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.0.2", - "@csstools/css-calc": "^2.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3" - } - }, - "packages/blockly/node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.4", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.3" - } - }, - "packages/blockly/node_modules/@csstools/css-tokenizer": { - "version": "3.0.3", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "packages/blockly/node_modules/@es-joy/jsdoccomment": { "version": "0.52.0", "dev": true, @@ -3169,6 +3720,7 @@ }, "packages/blockly/node_modules/agent-base": { "version": "7.1.3", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -4318,17 +4870,6 @@ "node": ">=0.10.0" } }, - "packages/blockly/node_modules/cssstyle": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.1.1", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, "packages/blockly/node_modules/d": { "version": "1.0.1", "dev": true, @@ -4351,19 +4892,9 @@ "node": ">= 12" } }, - "packages/blockly/node_modules/data-urls": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "packages/blockly/node_modules/debug": { "version": "4.4.1", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4406,10 +4937,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/blockly/node_modules/decimal.js": { - "version": "10.5.0", - "license": "MIT" - }, "packages/blockly/node_modules/decode-uri-component": { "version": "0.2.2", "dev": true, @@ -4655,6 +5182,7 @@ }, "packages/blockly/node_modules/entities": { "version": "4.5.0", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -6306,6 +6834,7 @@ }, "packages/blockly/node_modules/http-proxy-agent": { "version": "7.0.2", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -6343,6 +6872,7 @@ }, "packages/blockly/node_modules/https-proxy-agent": { "version": "7.0.6", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -6354,6 +6884,7 @@ }, "packages/blockly/node_modules/iconv-lite": { "version": "0.6.3", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6558,10 +7089,6 @@ "node": ">=0.10.0" } }, - "packages/blockly/node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "license": "MIT" - }, "packages/blockly/node_modules/is-promise": { "version": "2.2.2", "dev": true, @@ -6703,63 +7230,6 @@ "node": ">=12.0.0" } }, - "packages/blockly/node_modules/jsdom": { - "version": "26.1.0", - "license": "MIT", - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "packages/blockly/node_modules/jsdom/node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "packages/blockly/node_modules/jsdom/node_modules/whatwg-encoding": { - "version": "3.1.1", - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, "packages/blockly/node_modules/json-buffer": { "version": "3.0.1", "dev": true, @@ -7369,6 +7839,7 @@ }, "packages/blockly/node_modules/ms": { "version": "2.1.3", + "dev": true, "license": "MIT" }, "packages/blockly/node_modules/mute-stdout": { @@ -7474,10 +7945,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "packages/blockly/node_modules/nwsapi": { - "version": "2.2.20", - "license": "MIT" - }, "packages/blockly/node_modules/object-assign": { "version": "4.1.1", "dev": true, @@ -7676,6 +8143,7 @@ }, "packages/blockly/node_modules/parse5": { "version": "7.2.1", + "dev": true, "license": "MIT", "dependencies": { "entities": "^4.5.0" @@ -8019,6 +8487,7 @@ }, "packages/blockly/node_modules/punycode": { "version": "2.3.1", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8268,10 +8737,6 @@ "dev": true, "license": "MIT" }, - "packages/blockly/node_modules/rrweb-cssom": { - "version": "0.8.0", - "license": "MIT" - }, "packages/blockly/node_modules/run-parallel": { "version": "1.2.0", "dev": true, @@ -8322,18 +8787,9 @@ }, "packages/blockly/node_modules/safer-buffer": { "version": "2.1.2", + "dev": true, "license": "MIT" }, - "packages/blockly/node_modules/saxes": { - "version": "6.0.0", - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "packages/blockly/node_modules/secure-compare": { "version": "3.0.1", "dev": true, @@ -8778,10 +9234,6 @@ "semver": "bin/semver.js" } }, - "packages/blockly/node_modules/symbol-tree": { - "version": "3.2.4", - "license": "MIT" - }, "packages/blockly/node_modules/synckit": { "version": "0.11.8", "dev": true, @@ -8847,20 +9299,6 @@ "next-tick": "1" } }, - "packages/blockly/node_modules/tldts": { - "version": "6.1.86", - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "packages/blockly/node_modules/tldts-core": { - "version": "6.1.86", - "license": "MIT" - }, "packages/blockly/node_modules/tmp": { "version": "0.2.5", "dev": true, @@ -8891,26 +9329,6 @@ "node": ">=10.13.0" } }, - "packages/blockly/node_modules/tough-cookie": { - "version": "5.1.2", - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "packages/blockly/node_modules/tr46": { - "version": "5.1.0", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "packages/blockly/node_modules/tree-kill": { "version": "1.2.2", "dev": true, @@ -9269,16 +9687,6 @@ "node": ">= 0.10" } }, - "packages/blockly/node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "packages/blockly/node_modules/wait-port": { "version": "1.1.0", "dev": true, @@ -9371,13 +9779,6 @@ } } }, - "packages/blockly/node_modules/webidl-conversions": { - "version": "7.0.0", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, "packages/blockly/node_modules/whatwg-encoding": { "version": "2.0.0", "dev": true, @@ -9391,22 +9792,12 @@ }, "packages/blockly/node_modules/whatwg-mimetype": { "version": "4.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "packages/blockly/node_modules/whatwg-url": { - "version": "14.2.0", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "packages/blockly/node_modules/which": { "version": "2.0.2", "dev": true, @@ -9450,6 +9841,7 @@ }, "packages/blockly/node_modules/ws": { "version": "8.18.3", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -9467,17 +9859,6 @@ } } }, - "packages/blockly/node_modules/xml-name-validator": { - "version": "5.0.0", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "packages/blockly/node_modules/xmlchars": { - "version": "2.2.0", - "license": "MIT" - }, "packages/blockly/node_modules/xtend": { "version": "4.0.2", "dev": true, diff --git a/packages/blockly/package.json b/packages/blockly/package.json index 87f2d4e98..11a2260a8 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -16,6 +16,9 @@ "author": { "name": "Neil Fraser" }, + "browser": { + "jsdom": false + }, "scripts": { "build": "gulp build", "build-debug": "gulp build --verbose --debug", @@ -120,7 +123,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260225.0.0", + "google-closure-compiler": "^20260315.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", @@ -133,6 +136,7 @@ "gulp-sourcemaps": "^3.0.0", "gulp-umd": "^2.0.0", "http-server": "^14.0.0", + "jsdom": "27.4.0", "json5": "^2.2.0", "markdown-tables-to-json": "^0.1.7", "mocha": "^11.3.0", @@ -147,10 +151,10 @@ "webdriverio": "^9.0.7", "yargs": "^17.2.1" }, - "dependencies": { - "jsdom": "26.1.0" - }, "engines": { "node": ">=22" + }, + "peerDependencies": { + "jsdom": "^27.4.0" } } From c862b5ef0e1fe221096a19c4e2e5b4fd24853a12 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 18 Mar 2026 12:45:01 -0700 Subject: [PATCH 017/200] feat: Beep when attempting constrained move on top-level block (#9635) * feat: Beep when attempting constrained move on top-level block * chore: Remove errant `only` * refactor: Add and use `playErrorBeep()` --- packages/blockly/core/dragging/block_drag_strategy.ts | 1 + packages/blockly/core/workspace_audio.ts | 7 +++++++ packages/blockly/tests/mocha/keyboard_movement_test.js | 3 +++ 3 files changed, 11 insertions(+) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 39dff646e..b017eaf56 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -380,6 +380,7 @@ export class BlockDragStrategy implements IDragStrategy { if (this.moveMode === MoveMode.CONSTRAINED) { showUnconstrainedMoveHint(this.workspace, true); + this.workspace.getAudioManager().playErrorBeep(); } } } diff --git a/packages/blockly/core/workspace_audio.ts b/packages/blockly/core/workspace_audio.ts index 7d27277a0..66d555cd4 100644 --- a/packages/blockly/core/workspace_audio.ts +++ b/packages/blockly/core/workspace_audio.ts @@ -138,6 +138,13 @@ export class WorkspaceAudio { oscillator.stop(this.context.currentTime + duration); } + /** + * Plays a standard error beep. + */ + async playErrorBeep() { + return this.beep(260); + } + /** * Returns whether or not playing sounds is currently allowed. * diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index 9d6ac0cdd..8374699ea 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -534,6 +534,7 @@ suite('Keyboard-driven movement', function () { suite('in constrained mode', function () { test('prompts to use unconstrained mode when no destinations are available', function () { const toastSpy = sinon.spy(Blockly.Toast, 'show'); + const beepSpy = sinon.spy(this.workspace.getAudioManager(), 'beep'); Blockly.getFocusManager().focusNode(this.element); const originalBounds = this.element.getBoundingRectangle(); startMove(this.workspace); @@ -547,6 +548,8 @@ suite('Keyboard-driven movement', function () { ? 'Hold ⌘ Command and use arrow keys to move freely, then Enter to accept the position' : 'Hold Ctrl and use arrow keys to move freely, then Enter to accept the position', ); + sinon.assert.calledOnce(beepSpy); + beepSpy.restore(); toastSpy.restore(); }); From 8e6798a0942ae52bc70162b6c1541ca7762cd657 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:24:58 -0400 Subject: [PATCH 018/200] fix: visit all connection candidates in move mode (#9641) * fix: visit all connection candidates in move mode * fix: remove unused parameters from doc * fix: correct findTraversalCandidate doc * chore: simplify instance variables * fix: remove unreachable return --- .../core/dragging/block_drag_strategy.ts | 277 ++++++++++-------- .../tests/mocha/keyboard_movement_test.js | 14 +- 2 files changed, 154 insertions(+), 137 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index b017eaf56..24e52ba95 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -31,14 +31,16 @@ import * as dom from '../utils/dom.js'; import * as svgMath from '../utils/svg_math.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; -/** Represents a nearby valid connection. */ -interface ConnectionCandidate { +/** Represents a valid pair of connections between the dragging block and a block on the workspace. */ +interface ConnectionPair { /** A connection on the dragging stack that is compatible with neighbour. */ local: RenderedConnection; - /** A nearby connection that is compatible with local. */ neighbour: RenderedConnection; +} +/** Represents a nearby valid connection. */ +interface ConnectionCandidate extends ConnectionPair { /** The distance between the local connection and the neighbour connection. */ distance: number; } @@ -73,11 +75,8 @@ export class BlockDragStrategy implements IDragStrategy { private dragging = false; - /** Where a constrained movement should start when traversing the tree. */ - private searchNode: RenderedConnection | null = null; - /** List of all connections available on the workspace. */ - private allConnections: RenderedConnection[] = []; + private allConnectionPairs: ConnectionPair[] = []; /** The current movement mode. */ private moveMode = MoveMode.UNCONSTRAINED; @@ -187,6 +186,7 @@ export class BlockDragStrategy implements IDragStrategy { if (this.shouldDisconnect(healStack)) { this.disconnectBlock(healStack); } + this.block.setDragging(true); this.workspace.getLayerManager()?.moveToDragLayer(this.block); this.getVisibleBubbles(this.block).forEach((bubble) => { @@ -196,13 +196,10 @@ export class BlockDragStrategy implements IDragStrategy { // For keyboard-driven moves, cache a list of valid connection points for // use in constrained moved mode. if (e instanceof KeyboardEvent) { - for (const topBlock of this.block.workspace.getTopBlocks(true)) { - this.allConnections.push(...this.getAllConnections(topBlock)); - } + this.cacheAllConnectionPairs(); // Scooch the block to be offset from the connection preview indicator. this.block.moveDuringDrag(this.startLoc); - this.connectionCandidate = this.createInitialCandidate(); const neighbour = this.updateConnectionPreview( this.block, new Coordinate(0, 0), @@ -228,6 +225,33 @@ export class BlockDragStrategy implements IDragStrategy { return this.block; } + /** + * Handles any setup for starting the drag, including disconnecting the block + * from any parent blocks. + */ + private cacheAllConnectionPairs() { + const connectionChecker = this.block.workspace.connectionChecker; + const workspaceConns = []; + this.allConnectionPairs = []; + const localConns = this.getLocalConnections(this.block); + for (const topBlock of this.block.workspace.getTopBlocks(true)) { + workspaceConns.push(...this.getAllConnections(topBlock)); + } + for (const neighbour of workspaceConns) { + for (const local of localConns) { + if ( + connectionChecker.canConnect(local, neighbour, true, Infinity) && + !neighbour.targetBlock()?.isInsertionMarker() + ) { + this.allConnectionPairs.push({ + local, + neighbour, + }); + } + } + } + } + /** * Returns an array of visible bubbles attached to the given block or its * descendants. @@ -297,17 +321,56 @@ export class BlockDragStrategy implements IDragStrategy { * @param healStack Whether or not to heal the stack after disconnecting. */ private disconnectBlock(healStack: boolean) { - this.startParentConn = - this.block.outputConnection?.targetConnection ?? - this.block.previousConnection?.targetConnection; - if (healStack) { - this.startChildConn = this.block.nextConnection?.targetConnection; - } - + this.storeInitialConnections(healStack); this.block.unplug(healStack); blockAnimation.disconnectUiEffect(this.block); } + /** + * Stores the dragging block's current parent or child connection before + * unplugging. This allows us to revert the drag cleanly. In keyboard move mode, + * the initial connection pair is also used as the first connection candidate. + */ + private storeInitialConnections(healStack: boolean) { + // Prioritze the block's parent connection (output or previous) if one exists. + let localParentConn: RenderedConnection | null = null; + let parentTargetConn: RenderedConnection | null = null; + + if (this.block.outputConnection?.isConnected()) { + localParentConn = this.block.outputConnection; + parentTargetConn = this.block.outputConnection.targetConnection; + } else if (this.block.previousConnection?.isConnected()) { + localParentConn = this.block.previousConnection; + parentTargetConn = this.block.previousConnection.targetConnection; + } + + this.startParentConn = parentTargetConn; + if (localParentConn && parentTargetConn) { + this.connectionCandidate = { + local: localParentConn, + neighbour: parentTargetConn, + distance: 0, + }; + } else { + // If there is no parent connection and we are moving a single block, + // use the next connection. + if (healStack) { + const localNextConn = this.block.nextConnection; + const nextTargetConn = localNextConn?.targetConnection; + + if (localNextConn && nextTargetConn) { + this.connectionCandidate = { + local: localNextConn, + neighbour: nextTargetConn, + distance: 0, + }; + } + + this.startChildConn = nextTargetConn; + } + } + } + /** Fire a UI event at the start of a block drag. */ private fireDragStartEvent() { const event = new (eventUtils.get(EventType.BLOCK_DRAG))( @@ -357,27 +420,41 @@ export class BlockDragStrategy implements IDragStrategy { // Handle the case where the drag has reached a possible connection. if (this.connectionCandidate) { - const neighbour = this.connectionCandidate.neighbour; - // The next constrained move will resume the search from the current - // candidate location. - this.searchNode = neighbour; if (this.moveMode === MoveMode.CONSTRAINED) { - // Position the moving block down and slightly to the right of the - // target connection. - this.block.moveDuringDrag( - new Coordinate( - neighbour.x + this.BLOCK_CONNECTION_OFFSET, - neighbour.y + this.BLOCK_CONNECTION_OFFSET, - ), - ); + const {local, neighbour} = this.connectionCandidate; + + const dx = neighbour.x - local.x; + const dy = neighbour.y - local.y; + + // Base aligned position + let x = this.startLoc!.x + dx; + let y = this.startLoc!.y + dy; + + // Decide offset direction + const becomingChild = + local.type === ConnectionType.PREVIOUS_STATEMENT || + local.type === ConnectionType.OUTPUT_VALUE; + + const offset = this.BLOCK_CONNECTION_OFFSET; + + // An offset is used to distinguish the block from insertion marker, + // while keeping the connection point visible. The offset direction + // changes based on the parent/child relationship of the blocks + // being connected. + if (becomingChild) { + x += offset; + y += offset; + } else { + x -= offset; + y -= offset; + } + + this.block.moveDuringDrag(new Coordinate(x, y)); } } else { // No connection was available or adequately close to the dragged block; - // clear out the search node since we have nowhere to search from, and // suggest using unconstrained mode to arbitrarily position the block if // we're in keyboard-driven constrained mode. - this.searchNode = null; - if (this.moveMode === MoveMode.CONSTRAINED) { showUnconstrainedMoveHint(this.workspace, true); this.workspace.getAudioManager().playErrorBeep(); @@ -399,7 +476,8 @@ export class BlockDragStrategy implements IDragStrategy { delta: Coordinate, ): RenderedConnection | undefined { const currCandidate = this.connectionCandidate; - const newCandidate = this.getConnectionCandidate(draggingBlock, delta); + const newCandidate = this.getConnectionCandidate(delta); + if (!newCandidate) { this.connectionPreviewer?.hidePreview(); this.connectionCandidate = null; @@ -489,21 +567,13 @@ export class BlockDragStrategy implements IDragStrategy { * compatible type (input, output, etc) and connection check. */ private getConnectionCandidate( - draggingBlock: BlockSvg, delta: Coordinate, ): ConnectionCandidate | null { - const localConns = this.getLocalConnections(draggingBlock); - let candidate = null; - if (this.moveMode === MoveMode.CONSTRAINED) { const direction = this.getDirectionToNewLocation( Coordinate.sum(this.startLoc!, delta), ); - candidate = this.findTraversalCandidate( - draggingBlock, - localConns, - direction, - ); + const candidate = this.findTraversalCandidate(direction); if (candidate) { return candidate; } @@ -511,7 +581,10 @@ export class BlockDragStrategy implements IDragStrategy { delta = new Coordinate(0, 0); } + // If we do not have a candidate yet, we fallback to the closest one nearby. let radius = this.getSearchRadius(); + const localConns = this.getLocalConnections(this.block); + let candidate = null; for (const conn of localConns) { const {connection: neighbour, radius: rad} = conn.closest(radius, delta); @@ -551,7 +624,25 @@ export class BlockDragStrategy implements IDragStrategy { if (lastOnStack && lastOnStack !== draggingBlock.nextConnection) { available.push(lastOnStack); } - return available; + + // Reversing the order of input connections provides a more natural traversal + // experience. With each move right/down, the dragging block should move in + // one of those directions (except when wrapping to the other end of the list). + const nonInputConnections = [ + draggingBlock.outputConnection, + draggingBlock.previousConnection, + draggingBlock.nextConnection, + ].filter(Boolean); // Removes falsy (null) values. + const inputConnections: RenderedConnection[] = []; + + for (const conn of available) { + if (!nonInputConnections.includes(conn)) { + inputConnections.push(conn); + } + } + inputConnections.reverse(); + + return [...nonInputConnections, ...inputConnections]; } /** @@ -600,7 +691,7 @@ export class BlockDragStrategy implements IDragStrategy { this.block.queueRender().then(() => this.disposeStep()); } - this.allConnections = []; + this.allConnectionPairs = []; } /** Disposes of any state at the end of the drag. */ @@ -680,97 +771,31 @@ export class BlockDragStrategy implements IDragStrategy { /** * Get the nearest valid candidate connection in traversal order. * - * @param draggingBlock The root block being dragged. - * @param localConns The list of connections on the dragging block(s) that are - * available to connect to. * @param direction The cardinal direction in which the block is being moved. * @returns A candidate connection and radius, or null if none was found. */ - findTraversalCandidate( - draggingBlock: BlockSvg, - localConns: RenderedConnection[], - direction: Direction, - ): ConnectionCandidate | null { - const connectionChecker = draggingBlock.workspace.connectionChecker; - let candidateConnection: ConnectionCandidate | null = null; - let potential: RenderedConnection | null = this.searchNode; - - while (potential && !candidateConnection) { - const potentialIndex = this.allConnections.indexOf(potential); + findTraversalCandidate(direction: Direction): ConnectionCandidate | null { + const currentPairIndex = this.allConnectionPairs.findIndex( + (pair) => + this.connectionCandidate?.local === pair.local && + this.connectionCandidate?.neighbour === pair.neighbour, + ); + if (currentPairIndex !== -1) { if (direction === Direction.UP || direction === Direction.LEFT) { - potential = - this.allConnections[potentialIndex - 1] ?? - this.allConnections[this.allConnections.length - 1]; + const nextPair = + this.allConnectionPairs[currentPairIndex - 1] ?? + this.allConnectionPairs[this.allConnectionPairs.length - 1]; + return {...nextPair, distance: 0}; } else if ( direction === Direction.DOWN || direction === Direction.RIGHT ) { - potential = - this.allConnections[potentialIndex + 1] ?? this.allConnections[0]; - } - - localConns.forEach((conn: RenderedConnection) => { - if ( - potential && - connectionChecker.canConnect(conn, potential, true, Infinity) && - !potential.targetBlock()?.isInsertionMarker() - ) { - candidateConnection = { - local: conn, - neighbour: potential, - distance: 0, - }; - } - }); - if (potential == this.searchNode) break; - } - return candidateConnection; - } - - /** - * Create a candidate representing where the block was previously connected. - * Used to render the block position after picking up the block but before - * moving during a drag. - * - * @returns A connection candidate representing where the block was at the - * start of the drag. - */ - private createInitialCandidate(): ConnectionCandidate | null { - this.searchNode = this.startParentConn ?? this.startChildConn; - - switch (this.searchNode?.type) { - case ConnectionType.INPUT_VALUE: { - if (this.block.outputConnection) { - return { - neighbour: this.searchNode, - local: this.block.outputConnection, - distance: 0, - }; - } - break; - } - case ConnectionType.NEXT_STATEMENT: { - if (this.block.previousConnection) { - return { - neighbour: this.searchNode, - local: this.block.previousConnection, - distance: 0, - }; - } - break; - } - case ConnectionType.PREVIOUS_STATEMENT: { - if (this.block.nextConnection) { - return { - neighbour: this.searchNode, - local: this.block.nextConnection, - distance: 0, - }; - } - break; + const nextPair = + this.allConnectionPairs[currentPairIndex + 1] ?? + this.allConnectionPairs[0]; + return {...nextPair, distance: 0}; } } - return null; } diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index 8374699ea..547bfdc1c 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -641,14 +641,10 @@ suite('Keyboard-driven movement', function () { * pressing right (or down) arrow n times. */ const EXPECTED_COMPLEX_RIGHT = [ - // TODO(#702): With the current behavior, certain connection - // candidates that can be found using the mouse are not visited when - // doing a keyboard move. They appear in the list below, but commented - // out for now. They should be uncommented if the behavior is changed. {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location again. - // {id: 'text_print', index: 0, ownIndex: 1}, // Previous to own next. + {id: 'text_print', index: 0, ownIndex: 1}, // Previous to own next. {id: 'text_print', index: 0, ownIndex: 4}, // Previous to own else input. - // {id: 'text_print', index: 0, ownIndex: 3}, // Previous to own if input. + {id: 'text_print', index: 0, ownIndex: 3}, // Previous to own if input. {id: 'text_print', index: 1, ownIndex: 0}, // Next. {id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input. {id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input. @@ -820,12 +816,8 @@ suite('Keyboard-driven movement', function () { * pressing ArrowRight n times. */ const EXPECTED_COMPLEX_RIGHT = EXPECTED_SIMPLE_RIGHT.concat([ - // TODO(#702): With the current behavior, certain connection - // candidates that can be found using the mouse are not visited when - // doing a keyboard move. They appear in the list below, but commented - // out for now. They should be uncommented if the behavior is changed. {id: 'join0', index: 0, ownIndex: 2}, // Unattached block to own input. - // {id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input. + {id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input. ]); /** * Expected connection candidates when moving row consisting of From 6059d1f5fc6b7769a92b30290a8a92aebb98f8c0 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 24 Mar 2026 08:13:04 -0700 Subject: [PATCH 019/200] feat: Add keyboard shortcut for disconnecting the selected block (#9650) --- packages/blockly/core/shortcut_items.ts | 29 +++ .../tests/mocha/shortcut_items_test.js | 207 +++++++++++++++++- 2 files changed, 233 insertions(+), 3 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index de13f0788..4d5a0c43e 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -47,6 +47,7 @@ export enum names { MOVE_DOWN = 'move_down', MOVE_LEFT = 'move_left', MOVE_RIGHT = 'move_right', + DISCONNECT = 'disconnect', } /** @@ -569,6 +570,33 @@ export function registerFocusWorkspace() { ShortcutRegistry.registry.register(contextMenuShortcut); } +/** + * Registers keyboard shortcut to disconnect the focused block. + */ +export function registerDisconnectBlock() { + const shiftX = ShortcutRegistry.registry.createSerializedKey(KeyCodes.X, [ + KeyCodes.SHIFT, + ]); + const disconnectShortcut: ShortcutRegistry.KeyboardShortcut = { + name: names.DISCONNECT, + preconditionFn: (workspace) => + !workspace.isDragging() && !workspace.isReadOnly(), + callback: (_workspace, event) => { + keyboardNavigationController.setIsActive(true); + const curNode = getFocusManager().getFocusedNode(); + if (!(curNode instanceof BlockSvg)) return false; + + const healStack = !(event instanceof KeyboardEvent && event.shiftKey); + eventUtils.setGroup(true); + curNode.unplug(healStack); + eventUtils.setGroup(false); + return true; + }, + keyCodes: [KeyCodes.X, shiftX], + }; + ShortcutRegistry.registry.register(disconnectShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -593,6 +621,7 @@ export function registerKeyboardNavigationShortcuts() { registerShowContextMenu(); registerMovementShortcuts(); registerFocusWorkspace(); + registerDisconnectBlock(); } registerDefaultShortcuts(); diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 608a65171..6edef8c3e 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -6,7 +6,10 @@ import * as Blockly from '../../build/src/core/blockly.js'; import {assert} from '../../node_modules/chai/index.js'; -import {defineStackBlock} from './test_helpers/block_definitions.js'; +import { + defineRowBlock, + defineStackBlock, +} from './test_helpers/block_definitions.js'; import { sharedTestSetup, sharedTestTeardown, @@ -21,6 +24,8 @@ suite('Keyboard Shortcut Items', function () { this.injectionDiv = this.workspace.getInjectionDiv(); Blockly.ContextMenuRegistry.registry.reset(); Blockly.ContextMenuItems.registerDefaultOptions(); + defineStackBlock(); + defineRowBlock(); }); teardown(function () { sharedTestTeardown.call(this); @@ -32,7 +37,6 @@ suite('Keyboard Shortcut Items', function () { * @return {Blockly.Block} The block being selected. */ function setSelectedBlock(workspace) { - defineStackBlock(); const block = workspace.newBlock('stack_block'); Blockly.common.setSelected(block); sinon.stub(Blockly.getFocusManager(), 'getFocusedNode').returns(block); @@ -44,7 +48,6 @@ suite('Keyboard Shortcut Items', function () { * @param {Blockly.Workspace} workspace The workspace to create a new block on. */ function setSelectedConnection(workspace) { - defineStackBlock(); const block = workspace.newBlock('stack_block'); sinon .stub(Blockly.getFocusManager(), 'getFocusedNode') @@ -548,4 +551,202 @@ suite('Keyboard Shortcut Items', function () { }); }); }); + + suite('Disconnect Block (X)', function () { + setup(function () { + this.blockA = this.workspace.newBlock('stack_block'); + this.blockB = this.workspace.newBlock('stack_block'); + this.blockC = this.workspace.newBlock('stack_block'); + this.blockD = this.workspace.newBlock('stack_block'); + + this.blockB.nextConnection.connect(this.blockC.previousConnection); + this.blockC.nextConnection.connect(this.blockD.previousConnection); + + this.blockE = this.workspace.newBlock('row_block'); + this.blockF = this.workspace.newBlock('row_block'); + this.blockG = this.workspace.newBlock('row_block'); + this.blockH = this.workspace.newBlock('row_block'); + for (const block of [ + this.blockE, + this.blockF, + this.blockG, + this.blockH, + ]) { + block.setInputsInline(false); + } + + this.blockF.inputList[0].connection.connect(this.blockG.outputConnection); + this.blockG.inputList[0].connection.connect(this.blockH.outputConnection); + + for (const block of this.workspace.getAllBlocks()) { + block.initSvg(); + block.render(); + } + }); + test('Does nothing for single top-level stack block', function () { + Blockly.getFocusManager().focusNode(this.blockA); + const bounds = this.blockA.getBoundingRectangle(); + + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.X), + ); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.blockA, + ); + assert.deepEqual(bounds, this.blockA.getBoundingRectangle()); + }); + + test('Does nothing for single top-level value block', function () { + Blockly.getFocusManager().focusNode(this.blockE); + const bounds = this.blockE.getBoundingRectangle(); + + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.X), + ); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.blockE, + ); + assert.deepEqual(bounds, this.blockE.getBoundingRectangle()); + }); + + test('Disconnects child blocks when triggered on top stack block', function () { + Blockly.getFocusManager().focusNode(this.blockB); + assert.isTrue(this.blockB.nextConnection.isConnected()); + assert.isTrue(this.blockC.previousConnection.isConnected()); + + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.X), + ); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.blockB, + ); + // Blocks B and C should have been disconnected. + assert.isFalse(this.blockB.nextConnection.isConnected()); + assert.isFalse(this.blockC.previousConnection.isConnected()); + + // Blocks C and D should remain connected. + assert.isTrue(this.blockC.nextConnection.isConnected()); + assert.isTrue(this.blockD.previousConnection.isConnected()); + }); + + test('Disconnects and heals stack when triggered on mid-stack block', function () { + Blockly.getFocusManager().focusNode(this.blockC); + assert.isTrue(this.blockC.nextConnection.isConnected()); + assert.isTrue(this.blockC.previousConnection.isConnected()); + + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.X), + ); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.blockC, + ); + // Block C should be disconnected + assert.isFalse(this.blockC.nextConnection.isConnected()); + assert.isFalse(this.blockC.previousConnection.isConnected()); + + // Blocks B and D should be connected to each other due to stack healing. + assert.isTrue(this.blockB.nextConnection.isConnected()); + assert.isTrue(this.blockD.previousConnection.isConnected()); + assert.strictEqual(this.blockB.nextConnection.targetBlock(), this.blockD); + assert.strictEqual( + this.blockD.previousConnection.targetBlock(), + this.blockB, + ); + }); + + test('Disconnects and heals stack when triggered on mid-row value block', function () { + Blockly.getFocusManager().focusNode(this.blockG); + assert.isTrue(this.blockF.inputList[0].connection.isConnected()); + assert.isTrue(this.blockG.outputConnection.isConnected()); + + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.X), + ); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.blockG, + ); + // Block G should be disconnected + assert.isFalse(this.blockG.outputConnection.isConnected()); + assert.isFalse(this.blockG.inputList[0].connection.isConnected()); + + // Blocks F and H should be connected to each other due to stack healing. + assert.isTrue(this.blockF.inputList[0].connection.isConnected()); + assert.isTrue(this.blockH.outputConnection.isConnected()); + assert.strictEqual( + this.blockF.inputList[0].connection.targetBlock(), + this.blockH, + ); + assert.strictEqual( + this.blockH.outputConnection.targetBlock(), + this.blockF, + ); + }); + + test('Includes subsequent stack blocks when triggered with Shift', function () { + Blockly.getFocusManager().focusNode(this.blockC); + assert.isTrue(this.blockC.nextConnection.isConnected()); + assert.isTrue(this.blockC.previousConnection.isConnected()); + + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.X, [ + Blockly.utils.KeyCodes.SHIFT, + ]), + ); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.blockC, + ); + // Block C should be disconnected from block B but still connected to + // Block D. + assert.isFalse(this.blockB.nextConnection.isConnected()); + assert.isFalse(this.blockC.previousConnection.isConnected()); + assert.isTrue(this.blockC.nextConnection.isConnected()); + assert.strictEqual(this.blockC.nextConnection.targetBlock(), this.blockD); + assert.strictEqual( + this.blockD.previousConnection.targetBlock(), + this.blockC, + ); + }); + + test('Includes subsequent value blocks when triggered with Shift', function () { + Blockly.getFocusManager().focusNode(this.blockG); + assert.isTrue(this.blockF.inputList[0].connection.isConnected()); + assert.isTrue(this.blockG.outputConnection.isConnected()); + + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.X, [ + Blockly.utils.KeyCodes.SHIFT, + ]), + ); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.blockG, + ); + // Block G should be disconnected from block F but still connected to + // Block H. + assert.isFalse(this.blockF.inputList[0].connection.isConnected()); + assert.isFalse(this.blockG.outputConnection.isConnected()); + assert.isTrue(this.blockG.inputList[0].connection.isConnected()); + assert.strictEqual( + this.blockG.inputList[0].connection.targetBlock(), + this.blockH, + ); + assert.strictEqual( + this.blockH.outputConnection.targetBlock(), + this.blockG, + ); + }); + }); }); From 1ad060819e7b2fe3c855c9a0d2ad50ed34f92d0a Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:41:39 -0400 Subject: [PATCH 020/200] feat: Stop on workspace during constrained move (#9649) --- .../core/dragging/block_drag_strategy.ts | 51 ++++++++++--------- .../tests/mocha/keyboard_movement_test.js | 3 ++ 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index 24e52ba95..f4578a941 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -573,18 +573,13 @@ export class BlockDragStrategy implements IDragStrategy { const direction = this.getDirectionToNewLocation( Coordinate.sum(this.startLoc!, delta), ); - const candidate = this.findTraversalCandidate(direction); - if (candidate) { - return candidate; - } - - delta = new Coordinate(0, 0); + return this.findTraversalCandidate(direction); } // If we do not have a candidate yet, we fallback to the closest one nearby. let radius = this.getSearchRadius(); const localConns = this.getLocalConnections(this.block); - let candidate = null; + let candidate: ConnectionCandidate | null = null; for (const conn of localConns) { const {connection: neighbour, radius: rad} = conn.closest(radius, delta); @@ -775,30 +770,40 @@ export class BlockDragStrategy implements IDragStrategy { * @returns A candidate connection and radius, or null if none was found. */ findTraversalCandidate(direction: Direction): ConnectionCandidate | null { - const currentPairIndex = this.allConnectionPairs.findIndex( + const pairs = this.allConnectionPairs; + if (direction === Direction.NONE || !pairs.length) { + return this.connectionCandidate; + } + const forwardTraversal = + direction === Direction.RIGHT || direction === Direction.DOWN; + const currentPairIndex = pairs.findIndex( (pair) => this.connectionCandidate?.local === pair.local && this.connectionCandidate?.neighbour === pair.neighbour, ); - if (currentPairIndex !== -1) { - if (direction === Direction.UP || direction === Direction.LEFT) { - const nextPair = - this.allConnectionPairs[currentPairIndex - 1] ?? - this.allConnectionPairs[this.allConnectionPairs.length - 1]; - return {...nextPair, distance: 0}; - } else if ( - direction === Direction.DOWN || - direction === Direction.RIGHT - ) { - const nextPair = - this.allConnectionPairs[currentPairIndex + 1] ?? - this.allConnectionPairs[0]; - return {...nextPair, distance: 0}; + + if (forwardTraversal) { + if (currentPairIndex === -1) { + return this.pairToCandidate(pairs[0]); + } else if (currentPairIndex === pairs.length - 1) { + return null; + } else { + return this.pairToCandidate(pairs[currentPairIndex + 1]); + } + } else { + if (currentPairIndex === -1) { + return this.pairToCandidate(pairs[pairs.length - 1]); + } else if (currentPairIndex === 0) { + return null; + } else { + return this.pairToCandidate(pairs[currentPairIndex - 1]); } } - return null; } + private pairToCandidate(pair: ConnectionPair): ConnectionCandidate { + return {...pair, distance: 0}; + } /** * Returns the cardinal direction that the block being dragged would have to * move in to reach the given location. diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index 547bfdc1c..115275e63 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -583,6 +583,7 @@ suite('Keyboard-driven movement', function () { {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. {id: 'controls_if', index: 1, ownIndex: 0}, // Next. {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + null, // Disconnected on workspace ]; /** * Expected connection candidates when moving STATEMENT_SIMPLE after @@ -653,6 +654,7 @@ suite('Keyboard-driven movement', function () { {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. {id: 'controls_if', index: 1, ownIndex: 0}, // Next. {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + null, // Disconnected on workspace ]; /** * Expected connection candidates when moving STATEMENT_COMPLEX after @@ -761,6 +763,7 @@ suite('Keyboard-driven movement', function () { {id: 'join2', index: 1, ownIndex: 0}, // Join block ADD0 input. {id: 'join2', index: 2, ownIndex: 0}, // Join block ADD1 input. // Skip input of unattached join block. + null, // Disconnected on workspace ]; /** * Expected connection candidates when moving BLOCK_SIMPLE, after From 5d304df504ead65ab72555ccaed16ce7afbc8881 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 31 Mar 2026 12:15:11 -0700 Subject: [PATCH 021/200] chore: Remove the keyboard-navigation plugin from the advanced playground (#9670) --- package-lock.json | 9 ------- packages/blockly/package.json | 1 - .../playgrounds/advanced_playground.html | 27 ------------------- 3 files changed, 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index dacc5c768..a02898fb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1986,7 +1986,6 @@ "devDependencies": { "@blockly/block-test": "^7.0.2", "@blockly/dev-tools": "^9.0.2", - "@blockly/keyboard-navigation": "^3.0.1", "@blockly/theme-modern": "^7.0.1", "@hyperjump/browser": "^1.1.4", "@hyperjump/json-schema": "^1.5.0", @@ -2145,14 +2144,6 @@ "node": "*" } }, - "packages/blockly/node_modules/@blockly/keyboard-navigation": { - "version": "3.0.1", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "blockly": "^12.3.0" - } - }, "packages/blockly/node_modules/@blockly/theme-dark": { "version": "8.0.1", "dev": true, diff --git a/packages/blockly/package.json b/packages/blockly/package.json index 11a2260a8..5ab927b43 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -105,7 +105,6 @@ "devDependencies": { "@blockly/block-test": "^7.0.2", "@blockly/dev-tools": "^9.0.2", - "@blockly/keyboard-navigation": "^3.0.1", "@blockly/theme-modern": "^7.0.1", "@hyperjump/browser": "^1.1.4", "@hyperjump/json-schema": "^1.5.0", diff --git a/packages/blockly/tests/playgrounds/advanced_playground.html b/packages/blockly/tests/playgrounds/advanced_playground.html index 5c00de6ee..a1a89446f 100644 --- a/packages/blockly/tests/playgrounds/advanced_playground.html +++ b/packages/blockly/tests/playgrounds/advanced_playground.html @@ -18,9 +18,6 @@ await loadScript( '../../node_modules/@blockly/theme-modern/dist/index.js', ); - await loadScript( - '../../node_modules/@blockly/keyboard-navigation/dist/index.js', - ); let kbNavigation; @@ -52,28 +49,6 @@ // Refresh theme. ws.setTheme(ws.getTheme()); }); - - // Keyboard navigation options. - const kbOptions = { - 'Enable keyboard navigation': false, - }; - gui.remember(kbOptions); - gui.add(kbOptions, 'Enable keyboard navigation').onChange((enabled) => { - setupKeyboardNav(enabled, playground); - }); - - // Set up keyboard navigation on page load - setupKeyboardNav(kbOptions['Enable keyboard navigation'], playground); - } - - function setupKeyboardNav(enabled, playground) { - if (enabled) { - kbNavigation = new KeyboardNavigation(playground.getWorkspace()); - } else { - if (kbNavigation) { - kbNavigation.dispose(); - } - } } function initPlayground() { @@ -128,8 +103,6 @@ }; Blockly.ContextMenuItems.registerCommentOptions(); - KeyboardNavigation.registerKeyboardNavigationStyles(); - // TODO: register the navigation-deferring toolbox. createPlayground( document.getElementById('root'), From 3fb96e4ecda94686c09bd79159e095e56a32c8be Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:23:45 -0400 Subject: [PATCH 022/200] feat: aria live region for announcements (#9653) * feat: aria live region for announcements * fix: code review and add tests * fix: better suite name * chore: remove unused function * fix: code review changes * chore: add back ability to remove role --- packages/blockly/core/css.ts | 7 + packages/blockly/core/inject.ts | 3 + packages/blockly/core/utils/aria.ts | 148 ++++++++++++++++- packages/blockly/tests/mocha/aria_test.js | 187 ++++++++++++++++++++++ packages/blockly/tests/mocha/index.html | 1 + 5 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 packages/blockly/tests/mocha/aria_test.js diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index 1e796b354..fdfa9c041 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -529,4 +529,11 @@ input[type=number] { ) { outline: none; } +.hiddenForAria { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} `; diff --git a/packages/blockly/core/inject.ts b/packages/blockly/core/inject.ts index ca62eb47f..8cbae9b61 100644 --- a/packages/blockly/core/inject.ts +++ b/packages/blockly/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,8 @@ export function inject( common.globalShortcutHandler, ); + aria.initializeGlobalAriaLiveRegion(subContainer); + return workspace; } diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index d997b8d0a..b324f8c60 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -6,12 +6,50 @@ // Former goog.module ID: Blockly.utils.aria +import * as dom from './dom.js'; + /** ARIA states/properties prefix. */ const ARIA_PREFIX = 'aria-'; /** ARIA role attribute. */ const ROLE_ATTRIBUTE = 'role'; +/** + * ARIA state values for LivePriority. + * Copied from Closure's goog.a11y.aria.LivePriority + */ +export enum LiveRegionAssertiveness { + // This information has the highest priority and assistive technologies + // SHOULD notify the user immediately. Because an interruption may disorient + // users or cause them to not complete their current task, authors SHOULD NOT + // use the assertive value unless the interruption is imperative. + ASSERTIVE = 'assertive', + // Updates to the region will not be presented to the user unless the + // assistive technology is currently focused on that region. + OFF = 'off', + // (Background change) Assistive technologies SHOULD announce the updates at + // the next graceful opportunity, such as at the end of speaking the current + // sentence or when the users pauses typing. + POLITE = 'polite', +} + +/** + * Customization options that can be passed when using `announceDynamicAriaState`. + */ +export interface DynamicAnnouncementOptions { + /** The custom ARIA `Role` that should be used for the announcement container. */ + role?: Role; + + /** + * How assertive the announcement should be. + * + * Important*: It was found through testing that `ASSERTIVE` announcements are + * often outright ignored by some screen readers, so it's generally recommended + * to always use `POLITE` unless specifically tested across supported readers. + */ + assertiveness?: LiveRegionAssertiveness; +} + /** * ARIA role values. * Copied from Closure's goog.a11y.aria.Role @@ -56,6 +94,8 @@ export enum Role { STATUS = 'status', } +const DEFAULT_LIVE_REGION_ROLE = Role.STATUS; + /** * ARIA states and properties. * Copied from Closure's goog.a11y.aria.State @@ -64,6 +104,9 @@ 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 that, if true, indicates that all of a changed region should + // be presented, instead of only parts. Value: one of {true, false}. + ATOMIC = 'atomic', // ARIA property defines the total number of columns in a table, grid, or // treegrid. // Value: integer. @@ -124,15 +167,32 @@ export enum State { } /** - * Sets the role of an element. + * Removes the ARIA role from an element. * - * Similar to Closure's goog.a11y.aria + * Similar to Closure's goog.a11y.aria.removeRole + * + * @param element DOM element to remove the role from. + */ +export function removeRole(element: Element) { + element.removeAttribute(ROLE_ATTRIBUTE); +} + +/** + * Sets the ARIA role of an element. If `roleName` is null, + * the role is removed. + * + * Similar to Closure's goog.a11y.aria.setRole * * @param element DOM node to set role of. - * @param roleName Role name. + * @param roleName Role name, or null to remove the role. */ -export function setRole(element: Element, roleName: Role) { - element.setAttribute(ROLE_ATTRIBUTE, roleName); +export function setRole(element: Element, roleName: Role | null) { + if (!roleName) { + console.log('Removing role from element', element, roleName); + removeRole(element); + } else { + element.setAttribute(ROLE_ATTRIBUTE, roleName); + } } /** @@ -156,3 +216,81 @@ export function setState( const attrStateName = ARIA_PREFIX + stateName; element.setAttribute(attrStateName, `${value}`); } + +let liveRegionElement: HTMLElement | null = null; + +/** + * Creates an ARIA live region under the specified parent Element to be used + * for all dynamic announcements via `announceDynamicAriaState`. This must be + * called only once and before any dynamic announcements can be made. + * + * @param parent The container element to which the live region will be appended. + */ +export function initializeGlobalAriaLiveRegion(parent: HTMLDivElement) { + if (liveRegionElement && document.contains(liveRegionElement)) { + return; + } + const ariaAnnouncementDiv = document.createElement('div'); + ariaAnnouncementDiv.textContent = ''; + ariaAnnouncementDiv.id = 'blocklyAriaAnnounce'; + dom.addClass(ariaAnnouncementDiv, 'hiddenForAria'); + setState(ariaAnnouncementDiv, State.LIVE, LiveRegionAssertiveness.POLITE); + setRole(ariaAnnouncementDiv, DEFAULT_LIVE_REGION_ROLE); + setState(ariaAnnouncementDiv, State.ATOMIC, true); + parent.appendChild(ariaAnnouncementDiv); + liveRegionElement = ariaAnnouncementDiv; +} + +let ariaAnnounceTimeout: ReturnType; +let addBreakingSpace = false; + +/** + * 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 is hidden from the + * visual DOM. This live region is designed to try and ensure the text is read, + * including if the same text is issued multiple times consecutively. Note that + * `initializeGlobalAriaLiveRegion` must be called before this can be used. + * + * 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 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 read to the user. + * @param options Custom options to configure the announcement. This defaults to + * the status role and polite assertiveness. + */ +export function announceDynamicAriaState( + text: string, + options?: DynamicAnnouncementOptions, +) { + if (!liveRegionElement) { + throw new Error('ARIA live region not initialized.'); + } + const ariaAnnouncementContainer = liveRegionElement; + const { + assertiveness = LiveRegionAssertiveness.POLITE, + role = DEFAULT_LIVE_REGION_ROLE, + } = options || {}; + + // We use a short delay so rapid successive calls collapse into a single + // announcement, and to ensure assistive technologies reliably detect the + // DOM change. + clearTimeout(ariaAnnounceTimeout); + ariaAnnounceTimeout = setTimeout(() => { + // Clear previous content. + ariaAnnouncementContainer.replaceChildren(); + setState(ariaAnnouncementContainer, State.LIVE, assertiveness); + setRole(ariaAnnouncementContainer, role); + + const span = document.createElement('span'); + // The non-breaking space toggle ensures otherwise identical consecutive + // messages are still announced. + span.textContent = text + (addBreakingSpace ? '\u00A0' : ''); + addBreakingSpace = !addBreakingSpace; + ariaAnnouncementContainer.appendChild(span); + }, 10); +} diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js new file mode 100644 index 000000000..960f8e69b --- /dev/null +++ b/packages/blockly/tests/mocha/aria_test.js @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/index.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Aria', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.liveRegion = document.getElementById('blocklyAriaAnnounce'); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('live region is created', function () { + assert.isNotNull(this.liveRegion); + }); + + test('live region has polite aria-live', function () { + assert.equal(this.liveRegion.getAttribute('aria-live'), 'polite'); + }); + + test('live region has atomic true', function () { + assert.equal(this.liveRegion.getAttribute('aria-atomic'), 'true'); + }); + + test('live region has status role by default', function () { + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); + + test('live region is rendered for screen readers but visually hidden', function () { + const style = window.getComputedStyle(this.liveRegion); + + // Still rendered for screen readers + assert.notEqual(style.display, 'none'); + + // Visually hidden via hiddenForAria class + assert.equal(style.position, 'absolute'); + assert.equal(style.left, '-9999px'); + assert.equal(style.width, '1px'); + assert.equal(style.height, '1px'); + assert.equal(style.overflow, 'hidden'); + }); + + test('createLiveRegion only creates one region (singleton)', function () { + // Calling again should not create a duplicate. + Blockly.utils.aria.initializeGlobalAriaLiveRegion( + this.workspace.getInjectionDiv(), + ); + + const regions = this.workspace + .getInjectionDiv() + .querySelectorAll('#blocklyAriaAnnounce'); + + assert.equal(regions.length, 1); + }); + + test('announcement is delayed', function () { + Blockly.utils.aria.announceDynamicAriaState('Hello world'); + + assert.equal(this.liveRegion.textContent, ''); + + // Advance past the delay in announceDynamicAriaState. + this.clock.tick(11); + assert.include(this.liveRegion.textContent, 'Hello world'); + }); + + test('repeated announcements are unique', function () { + Blockly.utils.aria.announceDynamicAriaState('Block moved'); + this.clock.tick(11); + + const first = this.liveRegion.textContent; + + Blockly.utils.aria.announceDynamicAriaState('Block moved'); + this.clock.tick(11); + + const second = this.liveRegion.textContent; + + assert.notEqual(first, second); + }); + + test('last write wins when called rapidly', function () { + Blockly.utils.aria.announceDynamicAriaState('First message'); + Blockly.utils.aria.announceDynamicAriaState('Second message'); + Blockly.utils.aria.announceDynamicAriaState('Final message'); + + this.clock.tick(11); + + assert.include(this.liveRegion.textContent, 'Final message'); + }); + + test('assertive option sets aria-live assertive', function () { + Blockly.utils.aria.announceDynamicAriaState('Warning', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE, + role: null, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('aria-live'), 'assertive'); + }); + + test('role option updates role attribute', function () { + Blockly.utils.aria.announceDynamicAriaState('Alert message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + }); + + test('role and text update after delay', function () { + // Initial announcement to establish baseline role + text. + Blockly.utils.aria.announceDynamicAriaState('Initial message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.STATUS, + }); + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + const initialText = this.liveRegion.textContent; + + // Now announce with different role. + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + // Before delay: role and text should not have changed yet. + this.clock.tick(5); + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + assert.equal(this.liveRegion.textContent, initialText); + + // After delay: both should update. + this.clock.tick(6); + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + assert.include(this.liveRegion.textContent, 'Group message'); + }); + test('missing role does not clear default status role', function () { + Blockly.utils.aria.announceDynamicAriaState('Hello world'); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); + test('custom role overrides default status role', function () { + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + }); + test('role reverts to status after custom role when role not provided', function () { + // First: default + Blockly.utils.aria.announceDynamicAriaState('Normal message'); + this.clock.tick(11); + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + + // Second: custom role + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + this.clock.tick(11); + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + + // Third: no role provided should revert to default status. + Blockly.utils.aria.announceDynamicAriaState('Back to normal'); + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); +}); diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index 012bfe201..e75b145d5 100644 --- a/packages/blockly/tests/mocha/index.html +++ b/packages/blockly/tests/mocha/index.html @@ -159,6 +159,7 @@ import {javascriptGenerator} from '../../build/javascript.loader.mjs'; // Import tests. + import './aria_test.js'; import './block_json_test.js'; import './block_test.js'; import './clipboard_test.js'; From 05af6b6711b25d6fe51d87586454262428bef7b8 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 1 Apr 2026 08:14:12 -0700 Subject: [PATCH 023/200] feat!: Add support for keyboard navigation (#9634) * refactor!: Merge `Marker`, `LineCursor` and `Navigator` * refactor!: Use the Navigator to traverse the toolbox and flyout. * feat: Register keyboard shortcuts for navigation * test: Fix and add tests * chore: Export `ToolboxNavigator` * chore: Make the linter happy * chore: Reorganize files * chore: Fix docstrings * chore: Fix variable names * fix: Focus the flyout on T for simple toolboxes * test: Add tests for focus toolbox shortcut * refactor: Remove `WorkspaceSvg.keyboardAccessibilityMode` * refactor: Simplify navigation logic * fix: Fix tests * chore: Normalize imports * fix: Fix bad merge resolution * fix: Fix docstrings * fix: Fix navigation down on blocks with a statement input and no next connection * fix: Be more defensive about navigating to connections * fix: Use FlyoutButton IDs as row IDs --- packages/blockly/core/block_svg.ts | 54 + packages/blockly/core/blockly.ts | 31 +- .../blockly/core/comments/comment_editor.ts | 10 +- packages/blockly/core/field_input.ts | 18 +- packages/blockly/core/flyout_base.ts | 88 +- packages/blockly/core/flyout_button.ts | 7 + packages/blockly/core/flyout_navigator.ts | 24 - packages/blockly/core/inputs/input.ts | 35 +- .../interfaces/i_collapsible_toolbox_item.ts | 20 +- packages/blockly/core/interfaces/i_flyout.ts | 3 +- .../core/interfaces/i_focusable_tree.ts | 9 + .../core/interfaces/i_navigation_policy.ts | 13 + .../interfaces/i_selectable_toolbox_item.ts | 17 +- packages/blockly/core/interfaces/i_toolbox.ts | 5 + .../blockly/core/interfaces/i_toolbox_item.ts | 23 + .../keyboard_nav/block_navigation_policy.ts | 213 ---- .../connection_navigation_policy.ts | 155 --- .../keyboard_nav/flyout_navigation_policy.ts | 111 --- .../blockly/core/keyboard_nav/line_cursor.ts | 414 -------- packages/blockly/core/keyboard_nav/marker.ts | 86 -- .../block_comment_navigation_policy.ts | 16 +- .../block_navigation_policy.ts | 183 ++++ .../comment_bar_button_navigation_policy.ts | 16 +- .../comment_editor_navigation_policy.ts | 16 +- .../connection_navigation_policy.ts | 151 +++ .../field_navigation_policy.ts | 22 +- .../flyout_button_navigation_policy.ts | 16 +- .../flyout_separator_navigation_policy.ts | 15 +- .../icon_navigation_policy.ts | 28 +- .../toolbox_item_navigation_policy.ts | 105 ++ .../workspace_comment_navigation_policy.ts | 31 +- .../workspace_navigation_policy.ts | 16 +- .../navigators/flyout_navigator.ts | 60 ++ .../core/keyboard_nav/navigators/navigator.ts | 526 ++++++++++ .../navigators/toolbox_navigator.ts | 46 + packages/blockly/core/marker_manager.ts | 116 --- packages/blockly/core/navigator.ts | 123 --- packages/blockly/core/registry.ts | 3 - packages/blockly/core/shortcut_items.ts | 129 ++- packages/blockly/core/toolbox/category.ts | 10 + packages/blockly/core/toolbox/separator.ts | 7 + packages/blockly/core/toolbox/toolbox.ts | 138 +-- packages/blockly/core/toolbox/toolbox_item.ts | 7 + packages/blockly/core/workspace_svg.ts | 49 +- packages/blockly/tests/mocha/cursor_test.js | 922 ------------------ .../tests/mocha/field_checkbox_test.js | 3 - .../tests/mocha/field_textinput_test.js | 2 + packages/blockly/tests/mocha/index.html | 9 +- .../tests/mocha/keyboard_navigation_test.js | 403 ++++++++ .../blockly/tests/mocha/navigation_test.js | 50 +- .../tests/mocha/shortcut_items_test.js | 50 + .../tests/mocha/shortcut_registry_test.js | 5 +- .../test_helpers/navigation_test_blocks.js | 171 ++++ packages/blockly/tests/mocha/toolbox_test.js | 478 +++++---- 54 files changed, 2473 insertions(+), 2785 deletions(-) delete mode 100644 packages/blockly/core/flyout_navigator.ts delete mode 100644 packages/blockly/core/keyboard_nav/block_navigation_policy.ts delete mode 100644 packages/blockly/core/keyboard_nav/connection_navigation_policy.ts delete mode 100644 packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts delete mode 100644 packages/blockly/core/keyboard_nav/line_cursor.ts delete mode 100644 packages/blockly/core/keyboard_nav/marker.ts rename packages/blockly/core/keyboard_nav/{ => navigation_policies}/block_comment_navigation_policy.ts (81%) create mode 100644 packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts rename packages/blockly/core/keyboard_nav/{ => navigation_policies}/comment_bar_button_navigation_policy.ts (83%) rename packages/blockly/core/keyboard_nav/{ => navigation_policies}/comment_editor_navigation_policy.ts (73%) create mode 100644 packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts rename packages/blockly/core/keyboard_nav/{ => navigation_policies}/field_navigation_policy.ts (77%) rename packages/blockly/core/keyboard_nav/{ => navigation_policies}/flyout_button_navigation_policy.ts (80%) rename packages/blockly/core/keyboard_nav/{ => navigation_policies}/flyout_separator_navigation_policy.ts (76%) rename packages/blockly/core/keyboard_nav/{ => navigation_policies}/icon_navigation_policy.ts (73%) create mode 100644 packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts rename packages/blockly/core/keyboard_nav/{ => navigation_policies}/workspace_comment_navigation_policy.ts (68%) rename packages/blockly/core/keyboard_nav/{ => navigation_policies}/workspace_navigation_policy.ts (81%) create mode 100644 packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts create mode 100644 packages/blockly/core/keyboard_nav/navigators/navigator.ts create mode 100644 packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts delete mode 100644 packages/blockly/core/marker_manager.ts delete mode 100644 packages/blockly/core/navigator.ts delete mode 100644 packages/blockly/tests/mocha/cursor_test.js create mode 100644 packages/blockly/tests/mocha/keyboard_navigation_test.js create mode 100644 packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 0bdb726b8..666436c1e 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -1905,4 +1905,58 @@ export class BlockSvg canBeFocused(): boolean { return true; } + + /** + * Returns a set of all of the parent blocks of the given block. + * + * @internal + * @returns A set of the parents of the given block. + */ + getParents(): Set { + const parents = new Set(); + let parent = this.getParent(); + while (parent) { + parents.add(parent); + parent = parent.getParent(); + } + + return parents; + } + + /** + * Returns a set of all of the parent blocks connected to an output of the + * given block or one of its parents. Also includes the given block. + * + * @internal + * @returns A set of the output-connected parents of the given block. + */ + getOutputParents(): Set { + const parents = new Set(); + parents.add(this); + let parent = this.outputConnection?.targetBlock(); + while (parent) { + parents.add(parent); + parent = parent.outputConnection?.targetBlock(); + } + + return parents; + } + + /** + * Returns an ID for the visual "row" this block is part of. + * + * @internal + */ + getRowId(): string { + const connectedInput = + this.outputConnection?.targetConnection?.getParentInput(); + // Blocks with an output value have the same ID as the input they're + // connected to. + if (connectedInput) { + return connectedInput.getRowId(); + } + + // All other blocks are their own row. + return this.id; + } } diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 7377ff909..3e28908d1 100644 --- a/packages/blockly/core/blockly.ts +++ b/packages/blockly/core/blockly.ts @@ -177,15 +177,13 @@ import { import {IVariableMap} from './interfaces/i_variable_map.js'; import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; import * as internalConstants from './internal_constants.js'; -import {LineCursor} from './keyboard_nav/line_cursor.js'; -import {Marker} from './keyboard_nav/marker.js'; +import {ToolboxNavigator} from './keyboard_nav/navigators/toolbox_navigator.js'; import { KeyboardNavigationController, keyboardNavigationController, } from './keyboard_navigation_controller.js'; import type {LayerManager} from './layer_manager.js'; import * as layers from './layers.js'; -import {MarkerManager} from './marker_manager.js'; import {Menu} from './menu.js'; import {MenuItem} from './menuitem.js'; import {MetricsManager} from './metrics_manager.js'; @@ -439,16 +437,21 @@ Names.prototype.populateProcedures = function ( }; // clang-format on -export * from './flyout_navigator.js'; export * from './interfaces/i_navigation_policy.js'; -export * from './keyboard_nav/block_navigation_policy.js'; -export * from './keyboard_nav/connection_navigation_policy.js'; -export * from './keyboard_nav/field_navigation_policy.js'; -export * from './keyboard_nav/flyout_button_navigation_policy.js'; -export * from './keyboard_nav/flyout_navigation_policy.js'; -export * from './keyboard_nav/flyout_separator_navigation_policy.js'; -export * from './keyboard_nav/workspace_navigation_policy.js'; -export * from './navigator.js'; +export * from './keyboard_nav/navigation_policies/block_comment_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/block_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/comment_editor_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/connection_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/field_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/flyout_button_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/flyout_separator_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/icon_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/toolbox_item_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/workspace_comment_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/workspace_navigation_policy.js'; +export * from './keyboard_nav/navigators/flyout_navigator.js'; +export * from './keyboard_nav/navigators/navigator.js'; export * from './toast.js'; // Re-export submodules that no longer declareLegacyNamespace. @@ -471,7 +474,6 @@ export { DragTarget, Events, Extensions, - LineCursor, Procedures, ShortcutItems, Themes, @@ -596,8 +598,6 @@ export { KeyboardNavigationController, LabelFlyoutInflater, LayerManager, - Marker, - MarkerManager, Menu, MenuGenerator, MenuGeneratorFunction, @@ -619,6 +619,7 @@ export { Toolbox, ToolboxCategory, ToolboxItem, + ToolboxNavigator, ToolboxSeparator, Trashcan, UnattachedFieldError, diff --git a/packages/blockly/core/comments/comment_editor.ts b/packages/blockly/core/comments/comment_editor.ts index 92c92fa54..5d41a7c38 100644 --- a/packages/blockly/core/comments/comment_editor.ts +++ b/packages/blockly/core/comments/comment_editor.ts @@ -25,7 +25,7 @@ export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_'; /** The part of a comment that can be typed into. */ export class CommentEditor implements IFocusableNode { - id?: string; + id: string; /** The foreignObject containing the HTML text area. */ private foreignObject: SVGForeignObjectElement; @@ -42,7 +42,7 @@ export class CommentEditor implements IFocusableNode { constructor( public workspace: WorkspaceSvg, - commentId?: string, + commentId: string, private onFinishEditing?: () => void, ) { this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, { @@ -67,10 +67,8 @@ export class CommentEditor implements IFocusableNode { body.appendChild(this.textArea); this.foreignObject.appendChild(body); - if (commentId) { - this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER; - this.textArea.setAttribute('id', this.id); - } + this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER; + this.textArea.setAttribute('id', this.id); // Register browser event listeners for the user typing in the textarea. browserEvents.conditionalBind( diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index a8377ae05..dca834fb9 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -15,6 +15,7 @@ import './events/events_block_change.js'; import {BlockSvg} from './block_svg.js'; +import {IFocusableNode} from './blockly.js'; import * as browserEvents from './browser_events.js'; import * as bumpObjects from './bump_objects.js'; import * as dialog from './dialog.js'; @@ -28,7 +29,6 @@ import { UnattachedFieldError, } from './field.js'; import {getFocusManager} from './focus_manager.js'; -import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; import * as aria from './utils/aria.js'; @@ -600,16 +600,20 @@ export abstract class FieldInput extends Field< dropDownDiv.hideWithoutAnimation(); } else if (e.key === 'Tab') { e.preventDefault(); - const cursor = this.workspace_?.getCursor(); + const navigator = this.workspace_?.getNavigator(); const isValidDestination = (node: IFocusableNode | null) => (node instanceof FieldInput || (node instanceof BlockSvg && node.isSimpleReporter())) && node !== this.getSourceBlock(); - let target = e.shiftKey - ? cursor?.getPreviousNode(this, isValidDestination, false) - : cursor?.getNextNode(this, isValidDestination, false); + // eslint-disable-next-line @typescript-eslint/no-this-alias + let target: IFocusableNode | null | undefined = this; + do { + target = e.shiftKey + ? navigator?.getOutNode(target) + : navigator?.getInNode(target); + } while (target && !isValidDestination(target)); target = target instanceof BlockSvg && target.isSimpleReporter() ? target.getFields().next().value @@ -625,7 +629,9 @@ export abstract class FieldInput extends Field< targetSourceBlock instanceof BlockSvg ) { getFocusManager().focusNode(targetSourceBlock); - } else getFocusManager().focusNode(target); + } else { + getFocusManager().focusNode(target); + } target.showEditor(); } } diff --git a/packages/blockly/core/flyout_base.ts b/packages/blockly/core/flyout_base.ts index d89027ab4..fb2ff01c2 100644 --- a/packages/blockly/core/flyout_base.ts +++ b/packages/blockly/core/flyout_base.ts @@ -19,13 +19,11 @@ import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import {FlyoutItem} from './flyout_item.js'; import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; -import {FlyoutNavigator} from './flyout_navigator.js'; import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; -import {IFocusableNode} from './interfaces/i_focusable_node.js'; -import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import {FlyoutNavigator} from './keyboard_nav/navigators/flyout_navigator.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import * as renderManagement from './render_management.js'; @@ -42,7 +40,7 @@ import {WorkspaceSvg} from './workspace_svg.js'; */ export abstract class Flyout extends DeleteArea - implements IAutoHideable, IFlyout, IFocusableNode + implements IAutoHideable, IFlyout { /** * Position the flyout. @@ -797,86 +795,4 @@ export abstract class Flyout return null; } - - /** - * See IFocusableNode.getFocusableElement. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getFocusableElement(): HTMLElement | SVGElement { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.getFocusableTree. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getFocusableTree(): IFocusableTree { - throw new Error('Flyouts are not directly focusable.'); - } - - /** See IFocusableNode.onNodeFocus. */ - onNodeFocus(): void {} - - /** See IFocusableNode.onNodeBlur. */ - onNodeBlur(): void {} - - /** See IFocusableNode.canBeFocused. */ - canBeFocused(): boolean { - return false; - } - - /** - * See IFocusableNode.getRootFocusableNode. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getRootFocusableNode(): IFocusableNode { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.getRestoredFocusableNode. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getRestoredFocusableNode( - _previousNode: IFocusableNode | null, - ): IFocusableNode | null { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.getNestedTrees. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - getNestedTrees(): Array { - throw new Error('Flyouts are not directly focusable.'); - } - - /** - * See IFocusableNode.lookUpFocusableNode. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - lookUpFocusableNode(_id: string): IFocusableNode | null { - throw new Error('Flyouts are not directly focusable.'); - } - - /** See IFocusableTree.onTreeFocus. */ - onTreeFocus( - _node: IFocusableNode, - _previousTree: IFocusableTree | null, - ): void {} - - /** - * See IFocusableNode.onTreeBlur. - * - * @deprecated v12: Use the Flyout's workspace for focus operations, instead. - */ - onTreeBlur(_nextTree: IFocusableTree | null): void { - throw new Error('Flyouts are not directly focusable.'); - } } diff --git a/packages/blockly/core/flyout_button.ts b/packages/blockly/core/flyout_button.ts index ef8d0b332..1a61ae118 100644 --- a/packages/blockly/core/flyout_button.ts +++ b/packages/blockly/core/flyout_button.ts @@ -414,6 +414,13 @@ export class FlyoutButton canBeFocused(): boolean { return true; } + + /** + * Returns the ID of this FlyoutButton. + */ + getId() { + return this.id; + } } /** CSS for buttons and labels. See css.js for use. */ diff --git a/packages/blockly/core/flyout_navigator.ts b/packages/blockly/core/flyout_navigator.ts deleted file mode 100644 index a102ce817..000000000 --- a/packages/blockly/core/flyout_navigator.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {IFlyout} from './interfaces/i_flyout.js'; -import {FlyoutButtonNavigationPolicy} from './keyboard_nav/flyout_button_navigation_policy.js'; -import {FlyoutNavigationPolicy} from './keyboard_nav/flyout_navigation_policy.js'; -import {FlyoutSeparatorNavigationPolicy} from './keyboard_nav/flyout_separator_navigation_policy.js'; -import {Navigator} from './navigator.js'; - -export class FlyoutNavigator extends Navigator { - constructor(flyout: IFlyout) { - super(); - this.rules.push( - new FlyoutButtonNavigationPolicy(), - new FlyoutSeparatorNavigationPolicy(), - ); - this.rules = this.rules.map( - (rule) => new FlyoutNavigationPolicy(rule, flyout), - ); - } -} diff --git a/packages/blockly/core/inputs/input.ts b/packages/blockly/core/inputs/input.ts index 90d9ba7f5..ee5f7fdc0 100644 --- a/packages/blockly/core/inputs/input.ts +++ b/packages/blockly/core/inputs/input.ts @@ -17,7 +17,7 @@ import '../field_label.js'; import type {Block} from '../block.js'; import type {BlockSvg} from '../block_svg.js'; import type {Connection} from '../connection.js'; -import type {ConnectionType} from '../connection_type.js'; +import {ConnectionType} from '../connection_type.js'; import type {Field} from '../field.js'; import * as fieldRegistry from '../field_registry.js'; import {RenderedConnection} from '../rendered_connection.js'; @@ -314,4 +314,37 @@ export class Input { protected makeConnection(type: ConnectionType): Connection { return this.sourceBlock.makeConnection_(type); } + + /** + * Returns an ID for the visual "row" this input is part of. + * + * @internal + */ + getRowId(): string { + const inputs = this.getSourceBlock().inputList; + + // The first input in a block has the same ID as its parent block. + if (this === inputs[0]) { + return (this.getSourceBlock() as BlockSvg).getRowId(); + } + + const inputIndex = inputs.indexOf(this); + const precedingStatementInput = + inputs[inputIndex - 1].connection?.type === ConnectionType.NEXT_STATEMENT; + + // Each subsequent (a) external input (b) statement input or (c) inline + // input following a statement input is on its own row and has its own row + // ID. + if ( + !this.getSourceBlock().getInputsInline() || + this.connection?.type === ConnectionType.NEXT_STATEMENT || + precedingStatementInput + ) { + return `${this.getSourceBlock().id}-input${inputIndex}`; + } + + // Value inputs on a inline input block have the same row ID as their + // preceding input, since they're all on one row. + return inputs[inputIndex - 1].getRowId(); + } } diff --git a/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts b/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts index 0b591b4a6..6e29e5843 100644 --- a/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts @@ -6,7 +6,10 @@ // Former goog.module ID: Blockly.ICollapsibleToolboxItem -import type {ISelectableToolboxItem} from './i_selectable_toolbox_item.js'; +import { + type ISelectableToolboxItem, + isSelectableToolboxItem, +} from './i_selectable_toolbox_item.js'; import type {IToolboxItem} from './i_toolbox_item.js'; /** @@ -31,3 +34,18 @@ export interface ICollapsibleToolboxItem extends ISelectableToolboxItem { /** Toggles whether or not the toolbox item is expanded. */ toggleExpanded(): void; } + +/** + * Type guard that checks whether an object is an ICollapsibleToolboxItem. + */ +export function isCollapsibleToolboxItem( + obj: any, +): obj is ICollapsibleToolboxItem { + return ( + typeof obj.getChildToolboxItems === 'function' && + typeof obj.isExpanded === 'function' && + typeof obj.toggleExpanded === 'function' && + isSelectableToolboxItem(obj) && + obj.isCollapsible() + ); +} diff --git a/packages/blockly/core/interfaces/i_flyout.ts b/packages/blockly/core/interfaces/i_flyout.ts index 6906d5857..e826d17a8 100644 --- a/packages/blockly/core/interfaces/i_flyout.ts +++ b/packages/blockly/core/interfaces/i_flyout.ts @@ -10,13 +10,12 @@ import type {FlyoutItem} from '../flyout_item.js'; import type {Svg} from '../utils/svg.js'; import type {FlyoutDefinition} from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; -import {IFocusableTree} from './i_focusable_tree.js'; import type {IRegistrable} from './i_registrable.js'; /** * Interface for a flyout. */ -export interface IFlyout extends IRegistrable, IFocusableTree { +export interface IFlyout extends IRegistrable { /** Whether the flyout is laid out horizontally or not. */ horizontalLayout: boolean; diff --git a/packages/blockly/core/interfaces/i_focusable_tree.ts b/packages/blockly/core/interfaces/i_focusable_tree.ts index c33189fcd..d3ed925ca 100644 --- a/packages/blockly/core/interfaces/i_focusable_tree.ts +++ b/packages/blockly/core/interfaces/i_focusable_tree.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {Navigator} from '../keyboard_nav/navigators/navigator'; import type {IFocusableNode} from './i_focusable_node.js'; /** @@ -122,6 +123,14 @@ export interface IFocusableTree { * as in the case that Blockly is entirely losing DOM focus). */ onTreeBlur(nextTree: IFocusableTree | null): void; + + /** + * Returns a Navigator instance to be used to determine the navigation order + * between IFocusableNodes contained within this IFocusableTree. Generally + * this can just be an instance of Navigator, but trees may choose to return a + * subclass to customize navigation behavior within their context. + */ + getNavigator(): Navigator; } /** diff --git a/packages/blockly/core/interfaces/i_navigation_policy.ts b/packages/blockly/core/interfaces/i_navigation_policy.ts index 8e1ce6c10..e41ef29d2 100644 --- a/packages/blockly/core/interfaces/i_navigation_policy.ts +++ b/packages/blockly/core/interfaces/i_navigation_policy.ts @@ -44,6 +44,19 @@ export interface INavigationPolicy { */ getPreviousSibling(current: T): IFocusableNode | null; + /** + * Returns an ID corresponding to the visual "row" the given element is part + * of. All elements on the same visual row should share the same ID. For + * example, icons share their parent block's row ID, as do inline connected + * blocks or value inputs. Statement inputs, external inputs, or blocks + * connected to one another's previous or next connections form distinct + * visual rows and should have distinct row IDs. + * + * @param current The element to return the row ID of. + * @returns The row ID of the given element. + */ + getRowId(current: T): string; + /** * Returns whether or not the given instance should be reachable via keyboard * navigation. diff --git a/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts b/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts index 890d4e370..33fe4a7d4 100644 --- a/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_selectable_toolbox_item.ts @@ -7,7 +7,7 @@ // Former goog.module ID: Blockly.ISelectableToolboxItem import type {FlyoutItemInfoArray} from '../utils/toolbox'; -import type {IToolboxItem} from './i_toolbox_item.js'; +import {isToolboxItem, type IToolboxItem} from './i_toolbox_item.js'; /** * Interface for an item in the toolbox that can be selected. @@ -54,10 +54,17 @@ export interface ISelectableToolboxItem extends IToolboxItem { } /** - * Type guard that checks whether an IToolboxItem is an ISelectableToolboxItem. + * Type guard that checks whether an object is an ISelectableToolboxItem. */ export function isSelectableToolboxItem( - toolboxItem: IToolboxItem, -): toolboxItem is ISelectableToolboxItem { - return toolboxItem.isSelectable(); + obj: any, +): obj is ISelectableToolboxItem { + return ( + typeof obj.getName === 'function' && + typeof obj.getContents === 'function' && + typeof obj.setSelected === 'function' && + typeof obj.onClick === 'function' && + isToolboxItem(obj) && + obj.isSelectable() + ); } diff --git a/packages/blockly/core/interfaces/i_toolbox.ts b/packages/blockly/core/interfaces/i_toolbox.ts index f5d9c9fd7..614f19d9f 100644 --- a/packages/blockly/core/interfaces/i_toolbox.ts +++ b/packages/blockly/core/interfaces/i_toolbox.ts @@ -118,4 +118,9 @@ export interface IToolbox extends IRegistrable, IFocusableTree { /** Disposes of this toolbox. */ dispose(): void; + + /** + * Returns a list of items in this toolbox. + */ + getToolboxItems(): IToolboxItem[]; } diff --git a/packages/blockly/core/interfaces/i_toolbox_item.ts b/packages/blockly/core/interfaces/i_toolbox_item.ts index 661624fd7..25c4dab84 100644 --- a/packages/blockly/core/interfaces/i_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_toolbox_item.ts @@ -7,6 +7,7 @@ // Former goog.module ID: Blockly.IToolboxItem import type {IFocusableNode} from './i_focusable_node.js'; +import type {IToolbox} from './i_toolbox.js'; /** * Interface for an item in the toolbox. @@ -80,4 +81,26 @@ export interface IToolboxItem extends IFocusableNode { * @param isVisible True if category should be visible. */ setVisible_(isVisible: boolean): void; + + getParentToolbox(): IToolbox; +} + +/** + * Type guard that checks whether an object is an IToolboxItem. + */ +export function isToolboxItem(obj: any): obj is IToolboxItem { + return ( + obj && + typeof obj.init === 'function' && + typeof obj.getDiv === 'function' && + typeof obj.getId === 'function' && + typeof obj.getParent === 'function' && + typeof obj.getLevel === 'function' && + typeof obj.isSelectable === 'function' && + typeof obj.isCollapsible === 'function' && + typeof obj.dispose === 'function' && + typeof obj.getClickTarget === 'function' && + typeof obj.setVisible_ === 'function' && + typeof obj.getParentToolbox === 'function' + ); } diff --git a/packages/blockly/core/keyboard_nav/block_navigation_policy.ts b/packages/blockly/core/keyboard_nav/block_navigation_policy.ts deleted file mode 100644 index 9f56b5384..000000000 --- a/packages/blockly/core/keyboard_nav/block_navigation_policy.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {BlockSvg} from '../block_svg.js'; -import {ConnectionType} from '../connection_type.js'; -import type {Field} from '../field.js'; -import type {Icon} from '../icons/icon.js'; -import type {IBoundedElement} from '../interfaces/i_bounded_element.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import {isFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import type {ISelectable} from '../interfaces/i_selectable.js'; -import {RenderedConnection} from '../rendered_connection.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; - -/** - * Set of rules controlling keyboard navigation from a block. - */ -export class BlockNavigationPolicy implements INavigationPolicy { - /** - * Returns the first child of the given block. - * - * @param current The block to return the first child of. - * @returns The first field or input of the given block, if any. - */ - getFirstChild(current: BlockSvg): IFocusableNode | null { - const candidates = getBlockNavigationCandidates(current, true); - return candidates[0]; - } - - /** - * Returns the parent of the given block. - * - * @param current The block to return the parent of. - * @returns The top block of the given block's stack, or the connection to - * which it is attached. - */ - getParent(current: BlockSvg): IFocusableNode | null { - if (current.previousConnection?.targetBlock()) { - const surroundParent = current.getSurroundParent(); - if (surroundParent) return surroundParent; - } else if (current.outputConnection?.targetBlock()) { - return current.outputConnection.targetBlock(); - } - - return current.workspace; - } - - /** - * Returns the next peer node of the given block. - * - * @param current The block to find the following element of. - * @returns The first node of the next input/stack if the given block is a terminal - * block, or its next connection. - */ - getNextSibling(current: BlockSvg): IFocusableNode | null { - if (current.nextConnection?.targetBlock()) { - return current.nextConnection?.targetBlock(); - } else if (current.outputConnection?.targetBlock()) { - return navigateBlock(current, 1); - } else if (current.getSurroundParent()) { - return navigateBlock(current.getTopStackBlock(), 1); - } else if (this.getParent(current) instanceof WorkspaceSvg) { - return navigateStacks(current, 1); - } - - return null; - } - - /** - * Returns the previous peer node of the given block. - * - * @param current The block to find the preceding element of. - * @returns The block's previous/output connection, or the last - * connection/block of the previous block stack if it is a root block. - */ - getPreviousSibling(current: BlockSvg): IFocusableNode | null { - if (current.previousConnection?.targetBlock()) { - return current.previousConnection?.targetBlock(); - } else if (current.outputConnection?.targetBlock()) { - return navigateBlock(current, -1); - } else if (this.getParent(current) instanceof WorkspaceSvg) { - return navigateStacks(current, -1); - } - - return null; - } - - /** - * Returns whether or not the given block can be navigated to. - * - * @param current The instance to check for navigability. - * @returns True if the given block can be focused. - */ - isNavigable(current: BlockSvg): boolean { - return current.canBeFocused(); - } - - /** - * Returns whether the given object can be navigated from by this policy. - * - * @param current The object to check if this policy applies to. - * @returns True if the object is a BlockSvg. - */ - isApplicable(current: any): current is BlockSvg { - return current instanceof BlockSvg; - } -} - -/** - * Returns a list of the navigable children of the given block. - * - * @param block The block to retrieve the navigable children of. - * @returns A list of navigable/focusable children of the given block. - */ -function getBlockNavigationCandidates( - block: BlockSvg, - forward: boolean, -): IFocusableNode[] { - const candidates: IFocusableNode[] = block.getIcons(); - - 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 lastStackBlock = connectedBlock - .lastConnectionInStack(false) - ?.getSourceBlock(); - if (lastStackBlock) { - candidates.push(lastStackBlock); - } - } else { - candidates.push(connectedBlock); - } - } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { - candidates.push(input.connection as RenderedConnection); - } - } - - return candidates; -} - -/** - * Returns the next/previous stack relative to the given element's stack. - * - * @param current The element whose stack will be navigated relative to. - * @param delta The difference in index to navigate; positive values navigate - * to the nth next stack, while negative values navigate to the nth previous - * stack. - * @returns The first element in the stack offset by `delta` relative to the - * current element's stack, or the last element in the stack offset by - * `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) - .getTopBoundedElements(true) - .filter((element: IBoundedElement) => isFocusableNode(element)); - const currentIndex = stacks.indexOf( - current instanceof BlockSvg ? current.getRootBlock() : current, - ); - const targetIndex = currentIndex + delta; - let result: IFocusableNode | null = null; - if (targetIndex >= 0 && targetIndex < stacks.length) { - result = stacks[targetIndex]; - } else if (targetIndex < 0) { - result = stacks[stacks.length - 1]; - } else if (targetIndex >= stacks.length) { - result = stacks[0]; - } - - // When navigating to a previous block stack, our previous sibling is the last - // block in it. - if (delta < 0 && result instanceof BlockSvg) { - return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; - } - - return result; -} - -/** - * Returns the next navigable item relative to the provided block child. - * - * @param current The navigable block child item to navigate relative to. - * @param delta The difference in index to navigate; positive values navigate - * forward by n, while negative values navigate backwards by n. - * @returns The navigable block child offset by `delta` relative to `current`. - */ -export function navigateBlock( - current: Icon | Field | RenderedConnection | BlockSvg, - delta: number, -): IFocusableNode | null { - const block = - current instanceof BlockSvg - ? (current.outputConnection?.targetBlock() ?? current.getSurroundParent()) - : current.getSourceBlock(); - if (!(block instanceof BlockSvg)) return null; - - const candidates = getBlockNavigationCandidates(block, delta > 0); - const currentIndex = candidates.indexOf(current); - if (currentIndex === -1) return null; - - const targetIndex = currentIndex + delta; - if (targetIndex >= 0 && targetIndex < candidates.length) { - return candidates[targetIndex]; - } - - return null; -} diff --git a/packages/blockly/core/keyboard_nav/connection_navigation_policy.ts b/packages/blockly/core/keyboard_nav/connection_navigation_policy.ts deleted file mode 100644 index bf685d063..000000000 --- a/packages/blockly/core/keyboard_nav/connection_navigation_policy.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {BlockSvg} from '../block_svg.js'; -import {ConnectionType} from '../connection_type.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import {RenderedConnection} from '../rendered_connection.js'; -import {navigateBlock} from './block_navigation_policy.js'; - -/** - * Set of rules controlling keyboard navigation from a connection. - */ -export class ConnectionNavigationPolicy - implements INavigationPolicy -{ - /** - * Returns the first child of the given connection. - * - * @param current The connection to return the first child of. - * @returns The connection's first child element, or null if not none. - */ - getFirstChild(current: RenderedConnection): IFocusableNode | null { - if (current.getParentInput()) { - return current.targetConnection; - } - - return null; - } - - /** - * Returns the parent of the given connection. - * - * @param current The connection to return the parent of. - * @returns The given connection's parent connection or block. - */ - getParent(current: RenderedConnection): IFocusableNode | null { - return current.getSourceBlock(); - } - - /** - * Returns the next element following the given connection. - * - * @param current The connection to navigate from. - * @returns The field, input connection or block following this connection. - */ - getNextSibling(current: RenderedConnection): IFocusableNode | null { - if (current.getParentInput()) { - return navigateBlock(current, 1); - } else if (current.type === ConnectionType.NEXT_STATEMENT) { - const nextBlock = current.targetConnection; - // If this connection is the last one in the stack, our next sibling is - // the next block stack. - const sourceBlock = current.getSourceBlock(); - if ( - !nextBlock && - sourceBlock.getRootBlock().lastConnectionInStack(false) === current - ) { - const topBlocks = sourceBlock.workspace.getTopBlocks(true); - let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) + 1; - if (targetIndex >= topBlocks.length) { - targetIndex = 0; - } - const nextBlock = topBlocks[targetIndex]; - return this.getParentConnection(nextBlock) ?? nextBlock; - } - - return nextBlock; - } - - return current.getSourceBlock(); - } - - /** - * Returns the element preceding the given connection. - * - * @param current The connection to navigate from. - * @returns The field, input connection or block preceding this connection. - */ - getPreviousSibling(current: RenderedConnection): IFocusableNode | null { - if (current.getParentInput()) { - return navigateBlock(current, -1); - } else if ( - current.type === ConnectionType.PREVIOUS_STATEMENT || - current.type === ConnectionType.OUTPUT_VALUE - ) { - const previousConnection = - current.targetConnection && !current.targetConnection.getParentInput() - ? current.targetConnection - : null; - - // If this connection is a disconnected previous/output connection, our - // previous sibling is the previous block stack's last connection/block. - const sourceBlock = current.getSourceBlock(); - if ( - !previousConnection && - this.getParentConnection(sourceBlock.getRootBlock()) === current - ) { - const topBlocks = sourceBlock.workspace.getTopBlocks(true); - let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) - 1; - if (targetIndex < 0) { - targetIndex = topBlocks.length - 1; - } - const previousRootBlock = topBlocks[targetIndex]; - return ( - previousRootBlock.lastConnectionInStack(false) ?? previousRootBlock - ); - } - - return previousConnection; - } else if (current.type === ConnectionType.NEXT_STATEMENT) { - return current.getSourceBlock(); - } - return null; - } - - /** - * Gets the parent connection on a block. - * This is either an output connection, previous connection or undefined. - * If both connections exist return the one that is actually connected - * to another block. - * - * @param block The block to find the parent connection on. - * @returns The connection connecting to the parent of the block. - */ - protected getParentConnection(block: BlockSvg) { - if (!block.outputConnection || block.previousConnection?.isConnected()) { - return block.previousConnection; - } - return block.outputConnection; - } - - /** - * Returns whether or not the given connection can be navigated to. - * - * @param current The instance to check for navigability. - * @returns True if the given connection can be focused. - */ - isNavigable(current: RenderedConnection): boolean { - return current.canBeFocused(); - } - - /** - * Returns whether the given object can be navigated from by this policy. - * - * @param current The object to check if this policy applies to. - * @returns True if the object is a RenderedConnection. - */ - isApplicable(current: any): current is RenderedConnection { - return current instanceof RenderedConnection; - } -} diff --git a/packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts b/packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts deleted file mode 100644 index 6552c27b4..000000000 --- a/packages/blockly/core/keyboard_nav/flyout_navigation_policy.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {IFlyout} from '../interfaces/i_flyout.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; - -/** - * Generic navigation policy that navigates between items in the flyout. - */ -export class FlyoutNavigationPolicy implements INavigationPolicy { - /** - * Creates a new FlyoutNavigationPolicy instance. - * - * @param policy The policy to defer to for parents/children. - * @param flyout The flyout this policy will control navigation in. - */ - constructor( - private policy: INavigationPolicy, - private flyout: IFlyout, - ) {} - - /** - * Returns null to prevent navigating into flyout items. - * - * @param _current The flyout item to navigate from. - * @returns Null to prevent navigating into flyout items. - */ - getFirstChild(_current: T): IFocusableNode | null { - return null; - } - - /** - * Returns the parent of the given flyout item. - * - * @param current The flyout item to navigate from. - * @returns The parent of the given flyout item. - */ - getParent(current: T): IFocusableNode | null { - return this.policy.getParent(current); - } - - /** - * Returns the next item in the flyout relative to the given item. - * - * @param current The flyout item to navigate from. - * @returns The flyout item following the given one. - */ - getNextSibling(current: T): IFocusableNode | null { - const flyoutContents = this.flyout.getContents(); - if (!flyoutContents) return null; - - let index = flyoutContents.findIndex( - (flyoutItem) => flyoutItem.getElement() === current, - ); - - if (index === -1) return null; - index++; - if (index >= flyoutContents.length) { - index = 0; - } - - return flyoutContents[index].getElement(); - } - - /** - * Returns the previous item in the flyout relative to the given item. - * - * @param current The flyout item to navigate from. - * @returns The flyout item preceding the given one. - */ - getPreviousSibling(current: T): IFocusableNode | null { - const flyoutContents = this.flyout.getContents(); - if (!flyoutContents) return null; - - let index = flyoutContents.findIndex( - (flyoutItem) => flyoutItem.getElement() === current, - ); - - if (index === -1) return null; - index--; - if (index < 0) { - index = flyoutContents.length - 1; - } - - return flyoutContents[index].getElement(); - } - - /** - * Returns whether or not the given flyout item can be navigated to. - * - * @param current The instance to check for navigability. - * @returns True if the given flyout item can be focused. - */ - isNavigable(current: T): boolean { - return this.policy.isNavigable(current); - } - - /** - * Returns whether the given object can be navigated from by this policy. - * - * @param current The object to check if this policy applies to. - * @returns True if the object is a BlockSvg. - */ - isApplicable(current: any): current is T { - return this.policy.isApplicable(current); - } -} diff --git a/packages/blockly/core/keyboard_nav/line_cursor.ts b/packages/blockly/core/keyboard_nav/line_cursor.ts deleted file mode 100644 index 30770e47d..000000000 --- a/packages/blockly/core/keyboard_nav/line_cursor.ts +++ /dev/null @@ -1,414 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview The class representing a line cursor. - * A line cursor tries to traverse the blocks and connections on a block as if - * they were lines of code in a text editor. Previous and next traverse previous - * connections, next connections and blocks, while in and out traverse input - * connections and fields. - * @author aschmiedt@google.com (Abby Schmiedt) - */ - -import {BlockSvg} from '../block_svg.js'; -import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; -import {getFocusManager} from '../focus_manager.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import * as registry from '../registry.js'; -import type {WorkspaceSvg} from '../workspace_svg.js'; -import {Marker} from './marker.js'; - -/** - * Class for a line cursor. - */ -export class LineCursor extends Marker { - override type = 'cursor'; - - /** Locations to try moving the cursor to after a deletion. */ - private potentialNodes: IFocusableNode[] | null = null; - - /** - * @param workspace The workspace this cursor belongs to. - */ - constructor(protected readonly workspace: WorkspaceSvg) { - super(); - } - - /** - * Moves the cursor to the next block or workspace comment in the pre-order - * traversal. - * - * @returns The next node, or null if the current node is not set or there is - * no next value. - */ - next(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - const newNode = this.getNextNode( - curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, - ); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Moves the cursor to the next input connection or field - * in the pre order traversal. - * - * @returns The next node, or null if the current node is - * not set or there is no next value. - */ - in(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - - const newNode = this.getNextNode(curNode, () => true, true); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - /** - * Moves the cursor to the previous block or workspace comment in the - * pre-order traversal. - * - * @returns The previous node, or null if the current node is not set or there - * is no previous value. - */ - prev(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - const newNode = this.getPreviousNode( - curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, - ); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Moves the cursor to the previous input connection or field in the pre order - * traversal. - * - * @returns The previous node, or null if the current node - * is not set or there is no previous value. - */ - out(): IFocusableNode | null { - const curNode = this.getCurNode(); - if (!curNode) { - return null; - } - - const newNode = this.getPreviousNode(curNode, () => true, true); - - if (newNode) { - this.setCurNode(newNode); - } - return newNode; - } - - /** - * Returns true iff the node to which we would navigate if in() were - * called is the same as the node to which we would navigate if next() were - * called - in effect, if the LineCursor is at the end of the 'current - * line' of the program. - */ - atEndOfLine(): boolean { - const curNode = this.getCurNode(); - if (!curNode) return false; - const inNode = this.getNextNode(curNode, () => true, true); - const nextNode = this.getNextNode( - curNode, - (candidate: IFocusableNode | null) => { - return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() - ); - }, - true, - ); - - return inNode === nextNode; - } - - /** - * Uses pre order traversal to navigate the Blockly AST. This will allow - * a user to easily navigate the entire Blockly AST without having to go in - * and out levels on the tree. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param visitedNodes A set of previously visited nodes used to avoid cycles. - * @returns The next node in the traversal. - */ - private getNextNodeImpl( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - visitedNodes: Set = new Set(), - ): IFocusableNode | null { - if (!node || visitedNodes.has(node)) return null; - let newNode = - this.workspace.getNavigator().getFirstChild(node) || - this.workspace.getNavigator().getNextSibling(node); - - let target = node; - while (target && !newNode) { - const parent = this.workspace.getNavigator().getParent(target); - if (!parent) break; - newNode = this.workspace.getNavigator().getNextSibling(parent); - target = parent; - } - - if (isValid(newNode)) return newNode; - if (newNode) { - visitedNodes.add(node); - return this.getNextNodeImpl(newNode, isValid, visitedNodes); - } - return null; - } - - /** - * Get the next node in the AST, optionally allowing for loopback. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param loop Whether to loop around to the beginning of the workspace if no - * valid node was found. - * @returns The next node in the traversal. - */ - getNextNode( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - loop: boolean, - ): IFocusableNode | null { - if (!node || (!loop && this.getLastNode() === node)) return null; - - return this.getNextNodeImpl(node, isValid); - } - - /** - * Reverses the pre order traversal in order to find the previous node. This - * will allow a user to easily navigate the entire Blockly AST without having - * to go in and out levels on the tree. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param visitedNodes A set of previously visited nodes used to avoid cycles. - * @returns The previous node in the traversal or null if no previous node - * exists. - */ - private getPreviousNodeImpl( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - visitedNodes: Set = new Set(), - ): IFocusableNode | null { - if (!node || visitedNodes.has(node)) return null; - - const newNode = - this.getRightMostChild( - this.workspace.getNavigator().getPreviousSibling(node), - node, - ) || this.workspace.getNavigator().getParent(node); - - if (isValid(newNode)) return newNode; - if (newNode) { - visitedNodes.add(node); - return this.getPreviousNodeImpl(newNode, isValid, visitedNodes); - } - return null; - } - - /** - * Get the previous node in the AST, optionally allowing for loopback. - * - * @param node The current position in the AST. - * @param isValid A function true/false depending on whether the given node - * should be traversed. - * @param loop Whether to loop around to the end of the workspace if no valid - * node was found. - * @returns The previous node in the traversal or null if no previous node - * exists. - */ - getPreviousNode( - node: IFocusableNode | null, - isValid: (p1: IFocusableNode | null) => boolean, - loop: boolean, - ): IFocusableNode | null { - if (!node || (!loop && this.getFirstNode() === node)) return null; - - return this.getPreviousNodeImpl(node, isValid); - } - - /** - * Get the right most child of a node. - * - * @param node The node to find the right most child of. - * @returns The right most child of the given node, or the node if no child - * exists. - */ - private getRightMostChild( - node: IFocusableNode | null, - stopIfFound: IFocusableNode, - ): IFocusableNode | null { - if (!node) return node; - let newNode = this.workspace.getNavigator().getFirstChild(node); - if (!newNode || newNode === stopIfFound) return node; - for ( - let nextNode: IFocusableNode | null = newNode; - nextNode; - nextNode = this.workspace.getNavigator().getNextSibling(newNode) - ) { - if (nextNode === stopIfFound) break; - newNode = nextNode; - } - return this.getRightMostChild(newNode, stopIfFound); - } - - /** - * Prepare for the deletion of a block by making a list of nodes we - * could move the cursor to afterwards and save it to - * this.potentialNodes. - * - * After the deletion has occurred, call postDelete to move it to - * the first valid node on that list. - * - * The locations to try (in order of preference) are: - * - * - The current location. - * - The connection to which the deleted block is attached. - * - The block connected to the next connection of the deleted block. - * - The parent block of the deleted block. - * - A location on the workspace beneath the deleted block. - * - * N.B.: When block is deleted, all of the blocks conneccted to that - * block's inputs are also deleted, but not blocks connected to its - * next connection. - * - * @param deletedBlock The block that is being deleted. - */ - preDelete(deletedBlock: BlockSvg) { - const curNode = this.getCurNode(); - - const nodes: IFocusableNode[] = curNode ? [curNode] : []; - // The connection to which the deleted block is attached. - const parentConnection = - deletedBlock.previousConnection?.targetConnection ?? - deletedBlock.outputConnection?.targetConnection; - if (parentConnection) { - nodes.push(parentConnection); - } - // The block connected to the next connection of the deleted block. - const nextBlock = deletedBlock.getNextBlock(); - if (nextBlock) { - nodes.push(nextBlock); - } - // The parent block of the deleted block. - const parentBlock = deletedBlock.getParent(); - if (parentBlock) { - nodes.push(parentBlock); - } - // A location on the workspace beneath the deleted block. - // Move to the workspace. - nodes.push(this.workspace); - this.potentialNodes = nodes; - } - - /** - * Move the cursor to the first valid location in - * this.potentialNodes, following a block deletion. - */ - postDelete() { - const nodes = this.potentialNodes; - this.potentialNodes = null; - if (!nodes) throw new Error('must call preDelete first'); - for (const node of nodes) { - if (!this.getSourceBlockFromNode(node)?.disposed) { - this.setCurNode(node); - return; - } - } - throw new Error('no valid nodes in this.potentialNodes'); - } - - /** - * Get the current location of the cursor. - * - * Overrides normal Marker getCurNode to update the current node from the - * selected block. This typically happens via the selection listener but that - * is not called immediately when `Gesture` calls - * `Blockly.common.setSelected`. In particular the listener runs after showing - * the context menu. - * - * @returns The current field, connection, or block the cursor is on. - */ - getCurNode(): IFocusableNode | null { - return getFocusManager().getFocusedNode(); - } - - /** - * Set the location of the cursor and draw it. - * - * Overrides normal Marker setCurNode logic to call - * this.drawMarker() instead of this.drawer.draw() directly. - * - * @param newNode The new location of the cursor. - */ - setCurNode(newNode: IFocusableNode) { - getFocusManager().focusNode(newNode); - } - - /** - * Get the first navigable node on the workspace, or null if none exist. - * - * @returns The first navigable node on the workspace, or null. - */ - getFirstNode(): IFocusableNode | null { - return this.workspace.getNavigator().getFirstChild(this.workspace); - } - - /** - * Get the last navigable node on the workspace, or null if none exist. - * - * @returns The last navigable node on the workspace, or null. - */ - getLastNode(): IFocusableNode | null { - const first = this.getFirstNode(); - return this.getPreviousNode(first, () => true, true); - } -} - -registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor); diff --git a/packages/blockly/core/keyboard_nav/marker.ts b/packages/blockly/core/keyboard_nav/marker.ts deleted file mode 100644 index 0cd066c16..000000000 --- a/packages/blockly/core/keyboard_nav/marker.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * The class representing a marker. - * Used primarily for keyboard navigation to show a marked location. - * - * @class - */ -// Former goog.module ID: Blockly.Marker - -import {BlockSvg} from '../block_svg.js'; -import {Field} from '../field.js'; -import {Icon} from '../icons/icon.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import {RenderedConnection} from '../rendered_connection.js'; - -/** - * Class for a marker. - * This is used in keyboard navigation to save a location in the Blockly AST. - */ -export class Marker { - /** The colour of the marker. */ - colour: string | null = null; - - /** The current location of the marker. */ - protected curNode: IFocusableNode | null = null; - - /** The type of the marker. */ - type = 'marker'; - - /** - * Gets the current location of the marker. - * - * @returns The current field, connection, or block the marker is on. - */ - getCurNode(): IFocusableNode | null { - return this.curNode; - } - - /** - * Set the location of the marker and call the update method. - * - * @param newNode The new location of the marker, or null to remove it. - */ - setCurNode(newNode: IFocusableNode | null) { - this.curNode = newNode; - } - - /** Dispose of this marker. */ - dispose() { - this.curNode = null; - } - - /** - * Returns the block that the given node is a child of. - * - * @returns The parent block of the node if any, otherwise null. - */ - getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null { - if (node instanceof BlockSvg) { - return node; - } else if (node instanceof Field) { - return node.getSourceBlock() as BlockSvg; - } else if (node instanceof RenderedConnection) { - return node.getSourceBlock(); - } else if (node instanceof Icon) { - return node.getSourceBlock() as BlockSvg; - } - - return null; - } - - /** - * Returns the block that this marker's current node is a child of. - * - * @returns The parent block of the marker's current node if any, otherwise - * null. - */ - getSourceBlock(): BlockSvg | null { - return this.getSourceBlockFromNode(this.getCurNode()); - } -} diff --git a/packages/blockly/core/keyboard_nav/block_comment_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts similarity index 81% rename from packages/blockly/core/keyboard_nav/block_comment_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts index f2f1ab7e1..36b15db24 100644 --- a/packages/blockly/core/keyboard_nav/block_comment_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {TextInputBubble} from '../bubbles/textinput_bubble.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {TextInputBubble} from '../../bubbles/textinput_bubble.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from an TextInputBubble. @@ -54,6 +54,16 @@ export class BlockCommentNavigationPolicy return null; } + /** + * Returns the row ID of the given block comment. + * + * @param current The block comment to retrieve the row ID of. + * @returns The row ID of the given block comment. + */ + getRowId(current: TextInputBubble) { + return current.id; + } + /** * Returns whether or not the given block comment can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts new file mode 100644 index 000000000..183c92e7b --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../../block_svg.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../../rendered_connection.js'; + +/** + * Set of rules controlling keyboard navigation from a block. + */ +export class BlockNavigationPolicy implements INavigationPolicy { + /** + * Returns the first child of the given block. + * + * @param current The block to return the first child of. + * @returns The first icon, field, or input of the given block, if any. + */ + getFirstChild(current: BlockSvg): IFocusableNode | null { + return getBlockNavigationCandidates(current)[0]; + } + + /** + * Returns the parent of the given block. + * + * @param current The block to return the parent of. + * @returns The top block of the given block's stack, or the connection to + * which it is attached. + */ + getParent(current: BlockSvg): IFocusableNode | null { + if (current.previousConnection?.targetBlock()) { + const surroundParent = current.getSurroundParent(); + if (surroundParent) return surroundParent; + } else if (current.outputConnection?.targetBlock()) { + return current.outputConnection.targetBlock(); + } + + return current.workspace; + } + + /** + * Returns the next peer node of the given block. + * + * @param current The block to find the following element of. + * @returns The block's next connection, or the next peer on its parent block, + * otherwise null. + */ + getNextSibling(current: BlockSvg): IFocusableNode | null { + if (current.nextConnection) { + return current.nextConnection.targetBlock() ?? current.nextConnection; + } else if (current.outputConnection?.targetConnection) { + const parent = this.getParent(current) as BlockSvg; + return navigateBlock(parent, current, 1); + } + + return null; + } + + /** + * Returns the previous peer node of the given block. + * + * @param current The block to find the preceding element of. + * @returns The block's previous connection, or the previous peer on its + * parent block, otherwise null. + */ + getPreviousSibling(current: BlockSvg): IFocusableNode | null { + if (current.previousConnection?.targetBlock()) { + return current.previousConnection; + } else if (current.outputConnection?.targetBlock()) { + return navigateBlock( + current.outputConnection.targetBlock()!, + current, + -1, + ); + } + + return null; + } + + /** + * Returns the visual row ID of the given block. + * + * @param current The block to retrieve the row ID of. + * @returns The row ID of the given block. + */ + getRowId(current: BlockSvg) { + return current.getRowId(); + } + + /** + * Returns whether or not the given block can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given block can be focused. + */ + isNavigable(current: BlockSvg): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a BlockSvg. + */ + isApplicable(current: any): current is BlockSvg { + return current instanceof BlockSvg; + } +} + +/** + * Returns a list of the navigable children of the given block. + * + * @param block The block to retrieve the navigable children of. + * @returns A list of navigable/focusable children of the given block. + */ +function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] { + // Collapsed blocks have no navigable children. + if (block.isCollapsed()) return []; + + // Icons are navigable. + const candidates: IFocusableNode[] = block.getIcons(); + + for (const input of block.inputList) { + // Invisible inputs are not valid navigation candidates. + if (!input.isVisible()) continue; + + // Fields are navigable. + candidates.push(...input.fieldRow); + + // Connections on inputs are navigable. + const connection = input.connection; + if (!connection) continue; + candidates.push(connection as RenderedConnection); + + // Child blocks attached to inputs are navigable. + const attachedBlock = connection.targetBlock(); + if (!attachedBlock) continue; + candidates.push(attachedBlock as BlockSvg); + + // The last (empty) next connection in a child statement block stack is + // navigable. + const lastConnection = attachedBlock.lastConnectionInStack(false); + if (!lastConnection) continue; + candidates.push(lastConnection as RenderedConnection); + } + + // The block's next connection is navigable. + if (block.nextConnection) { + candidates.push(block.nextConnection); + } + + return candidates; +} + +/** + * Returns the next navigable item relative to the provided block child. + * + * @param block The block whose children should be navigated. + * @param current The navigable block child item to navigate relative to. + * @param delta The difference in index to navigate; positive values navigate + * forward by n, while negative values navigate backwards by n. + * @returns The navigable block child offset by `delta` relative to `current`. + */ +export function navigateBlock( + block: BlockSvg, + current: IFocusableNode, + delta: number, +): IFocusableNode | null { + const candidates = getBlockNavigationCandidates(block); + const currentIndex = candidates.indexOf(current); + if (currentIndex === -1) return null; + + const targetIndex = currentIndex + delta; + if (targetIndex >= 0 && targetIndex < candidates.length) { + return candidates[targetIndex]; + } + + return null; +} diff --git a/packages/blockly/core/keyboard_nav/comment_bar_button_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.ts similarity index 83% rename from packages/blockly/core/keyboard_nav/comment_bar_button_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.ts index 6654d2d8f..c03ddd925 100644 --- a/packages/blockly/core/keyboard_nav/comment_bar_button_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {CommentBarButton} from '../comments/comment_bar_button.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {CommentBarButton} from '../../comments/comment_bar_button.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a CommentBarButton. @@ -66,6 +66,16 @@ export class CommentBarButtonNavigationPolicy return null; } + /** + * Returns the row ID of the given CommentBarButton. + * + * @param current The CommentBarButton to retrieve the row ID of. + * @returns The row ID of the given CommentBarButton. + */ + getRowId(current: CommentBarButton) { + return current.getCommentView().commentId; + } + /** * Returns whether or not the given CommentBarButton can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/comment_editor_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/comment_editor_navigation_policy.ts similarity index 73% rename from packages/blockly/core/keyboard_nav/comment_editor_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/comment_editor_navigation_policy.ts index 456df8e97..9c54824c8 100644 --- a/packages/blockly/core/keyboard_nav/comment_editor_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/comment_editor_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {CommentEditor} from '../comments/comment_editor.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {CommentEditor} from '../../comments/comment_editor.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a comment editor. @@ -32,6 +32,16 @@ export class CommentEditorNavigationPolicy return null; } + /** + * Returns the row ID of the given comment editor. + * + * @param current The comment editor to retrieve the row ID of. + * @returns The row ID of the given comment editor. + */ + getRowId(current: CommentEditor) { + return current.id; + } + /** * Returns whether or not the given comment editor can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts new file mode 100644 index 000000000..7810ff82f --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigation_policies/connection_navigation_policy.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../../block_svg.js'; +import {ConnectionType} from '../../connection_type.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../../rendered_connection.js'; +import {navigateBlock} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a connection. + */ +export class ConnectionNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of a connection. + * + * @returns Null, as connections do not have children. + */ + getFirstChild(): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given connection. + * + * @param current The connection to return the parent of. + * @returns The given connection's parent block. + */ + getParent(current: RenderedConnection): IFocusableNode | null { + return current.getSourceBlock(); + } + + /** + * Returns the next element following the given connection. + * + * @param current The connection to navigate from. + * @returns The field, input connection or block following this connection. + */ + getNextSibling(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + return navigateBlock(current.getSourceBlock(), current, 1); + } else if ( + current.type === ConnectionType.NEXT_STATEMENT && + current.getSourceBlock().getSurroundParent() && + !current.targetConnection + ) { + return navigateBlock( + current.getSourceBlock().getSurroundParent()!, + current, + 1, + ); + } + + switch (current.type) { + case ConnectionType.NEXT_STATEMENT: + return current.targetConnection; + case ConnectionType.PREVIOUS_STATEMENT: + case ConnectionType.OUTPUT_VALUE: + return current.getSourceBlock(); + } + + return null; + } + + /** + * Returns the element preceding the given connection. + * + * @param current The connection to navigate from. + * @returns The field, input connection or block preceding this connection. + */ + getPreviousSibling(current: RenderedConnection): IFocusableNode | null { + if (current.getParentInput()) { + return navigateBlock( + current.getParentInput()!.getSourceBlock() as BlockSvg, + current, + -1, + ); + } + switch (current.type) { + case ConnectionType.NEXT_STATEMENT: + return current.getSourceBlock(); + case ConnectionType.PREVIOUS_STATEMENT: + case ConnectionType.OUTPUT_VALUE: + return current.targetConnection; + } + + return null; + } + + /** + * Returns the row ID of the given connection. + * + * @param current The connection to retrieve the row ID of. + * @returns The row ID of the given connection. + */ + getRowId(current: RenderedConnection) { + switch (current.type) { + case ConnectionType.NEXT_STATEMENT: + case ConnectionType.PREVIOUS_STATEMENT: + return current.id; + case ConnectionType.INPUT_VALUE: + return current.getParentInput()!.getRowId(); + case ConnectionType.OUTPUT_VALUE: + default: + return current.getSourceBlock().getRowId(); + } + } + + /** + * Returns whether or not the given connection can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given connection can be focused. + */ + isNavigable(current: RenderedConnection): boolean { + if (!current.canBeFocused()) return false; + + // Empty next connections on block stacks inside of a C shaped block are + // navigable. + if (current.type === ConnectionType.NEXT_STATEMENT) { + if (current.targetBlock()) return false; + + const rootBlock = + current.getSourceBlock().getRootBlock() ?? current.getSourceBlock(); + if (current === rootBlock.lastConnectionInStack(false)) return false; + + return true; + } + + // Empty input connections are navigable. + return ( + current.type === ConnectionType.INPUT_VALUE && !current.targetBlock() + ); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is a RenderedConnection. + */ + isApplicable(current: any): current is RenderedConnection { + return current instanceof RenderedConnection; + } +} diff --git a/packages/blockly/core/keyboard_nav/field_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/field_navigation_policy.ts similarity index 77% rename from packages/blockly/core/keyboard_nav/field_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/field_navigation_policy.ts index f9df406c2..6b0acff2e 100644 --- a/packages/blockly/core/keyboard_nav/field_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/field_navigation_policy.ts @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {BlockSvg} from '../block_svg.js'; -import {Field} from '../field.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import type {BlockSvg} from '../../block_svg.js'; +import {Field} from '../../field.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {navigateBlock} from './block_navigation_policy.js'; /** @@ -41,7 +41,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { * @returns The next field or input in the given field's block. */ getNextSibling(current: Field): IFocusableNode | null { - return navigateBlock(current, 1); + return navigateBlock(current.getSourceBlock() as BlockSvg, current, 1); } /** @@ -51,7 +51,17 @@ export class FieldNavigationPolicy implements INavigationPolicy> { * @returns The preceding field or input in the given field's block. */ getPreviousSibling(current: Field): IFocusableNode | null { - return navigateBlock(current, -1); + return navigateBlock(current.getSourceBlock() as BlockSvg, current, -1); + } + + /** + * Returns the row ID of the given field. + * + * @param current The field to retrieve the row ID of. + * @returns The row ID of the given field. + */ + getRowId(current: Field) { + return current.getParentInput().getRowId(); } /** diff --git a/packages/blockly/core/keyboard_nav/flyout_button_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_button_navigation_policy.ts similarity index 80% rename from packages/blockly/core/keyboard_nav/flyout_button_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/flyout_button_navigation_policy.ts index 6c39c3061..5cf8b4e53 100644 --- a/packages/blockly/core/keyboard_nav/flyout_button_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_button_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {FlyoutButton} from '../flyout_button.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {FlyoutButton} from '../../flyout_button.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a flyout button. @@ -54,6 +54,16 @@ export class FlyoutButtonNavigationPolicy return null; } + /** + * Returns the row ID of the given flyout button. + * + * @param current The flyout button to retrieve the row ID of. + * @returns The row ID of the given flyout button. + */ + getRowId(current: FlyoutButton) { + return current.getId(); + } + /** * Returns whether or not the given flyout button can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/flyout_separator_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_separator_navigation_policy.ts similarity index 76% rename from packages/blockly/core/keyboard_nav/flyout_separator_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/flyout_separator_navigation_policy.ts index eb7ca4eb7..8ea16a0ae 100644 --- a/packages/blockly/core/keyboard_nav/flyout_separator_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/flyout_separator_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {FlyoutSeparator} from '../flyout_separator.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {FlyoutSeparator} from '../../flyout_separator.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a flyout separator. @@ -31,6 +31,15 @@ export class FlyoutSeparatorNavigationPolicy return null; } + /** + * Returns the row ID of the given flyout separator. + * + * @returns Dummy row ID, as flyout separators are never navigable. + */ + getRowId() { + return 'error'; + } + /** * Returns whether or not the given flyout separator can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/icon_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts similarity index 73% rename from packages/blockly/core/keyboard_nav/icon_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts index 112239d06..d0d2b5ad1 100644 --- a/packages/blockly/core/keyboard_nav/icon_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {BlockSvg} from '../block_svg.js'; -import {getFocusManager} from '../focus_manager.js'; -import {CommentIcon} from '../icons/comment_icon.js'; -import {Icon} from '../icons/icon.js'; -import {MutatorIcon} from '../icons/mutator_icon.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {BlockSvg} from '../../block_svg.js'; +import {getFocusManager} from '../../focus_manager.js'; +import {CommentIcon} from '../../icons/comment_icon.js'; +import {Icon} from '../../icons/icon.js'; +import {MutatorIcon} from '../../icons/mutator_icon.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {navigateBlock} from './block_navigation_policy.js'; /** @@ -58,7 +58,7 @@ export class IconNavigationPolicy implements INavigationPolicy { * @returns The next icon, field or input following this icon, if any. */ getNextSibling(current: Icon): IFocusableNode | null { - return navigateBlock(current, 1); + return navigateBlock(current.getSourceBlock() as BlockSvg, current, 1); } /** @@ -68,7 +68,17 @@ export class IconNavigationPolicy implements INavigationPolicy { * @returns The icon's previous icon, if any. */ getPreviousSibling(current: Icon): IFocusableNode | null { - return navigateBlock(current, -1); + return navigateBlock(current.getSourceBlock() as BlockSvg, current, -1); + } + + /** + * Returns the row ID of the given icon. + * + * @param current The icon to retrieve the row ID of. + * @returns The row ID of the given icon. + */ + getRowId(current: Icon) { + return (current.getSourceBlock() as BlockSvg).getRowId(); } /** diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts new file mode 100644 index 000000000..f1b55fac5 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigation_policies/toolbox_item_navigation_policy.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {isCollapsibleToolboxItem} from '../../interfaces/i_collapsible_toolbox_item.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import { + isToolboxItem, + type IToolboxItem, +} from '../../interfaces/i_toolbox_item.js'; + +/** + * Set of rules controlling keyboard navigation from a toolbox item. + */ +export class ToolboxItemNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given toolbox item. + * + * @param current The toolbox item to return the first child of. + * @returns The child item of a collapsible toolbox item, otherwise null. + */ + getFirstChild(current: IToolboxItem): IFocusableNode | null { + if (isCollapsibleToolboxItem(current)) { + return current.getChildToolboxItems()[0]; + } + + return null; + } + + /** + * Returns the parent of the given toolbox item. + * + * @param current The toolbox item to return the parent of. + * @returns The parent toolbox item of the given toolbox item, if any. + */ + getParent(current: IToolboxItem): IFocusableNode | null { + return current.getParent(); + } + + /** + * Returns the next sibling of the given toolbox item. + * + * @param current The toolbox item to return the next sibling of. + * @returns The next toolbox item, or null. + */ + getNextSibling(current: IToolboxItem): IFocusableNode | null { + const items = current.getParentToolbox().getToolboxItems(); + const index = items.indexOf(current); + return items[index + 1] ?? null; + } + + /** + * Returns the previous sibling of the given toolbox item. + * + * @param current The toolbox item to return the previous sibling of. + * @returns The previous toolbox item, or null. + */ + getPreviousSibling(current: IToolboxItem): IFocusableNode | null { + const items = current.getParentToolbox().getToolboxItems(); + const index = items.indexOf(current); + return items[index - 1] ?? null; + } + + /** + * Returns the row ID of the given toolbox item. + * + * @param current The toolbox item to retrieve the row ID of. + * @returns The row ID of the given toolbox item. + */ + getRowId(current: IToolboxItem) { + return current.getId(); + } + + /** + * Returns whether or not the given toolbox item can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given toolbox item can be focused. + */ + isNavigable(current: IToolboxItem): boolean { + return current.canBeFocused() && this.allParentsExpanded(current); + } + + private allParentsExpanded(current: IToolboxItem): boolean { + const parent = current.getParent(); + if (!parent || !isCollapsibleToolboxItem(parent)) return true; + + return parent.isExpanded() && this.allParentsExpanded(parent); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an IToolboxItem. + */ + isApplicable(current: any): current is IToolboxItem { + return isToolboxItem(current); + } +} diff --git a/packages/blockly/core/keyboard_nav/workspace_comment_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_comment_navigation_policy.ts similarity index 68% rename from packages/blockly/core/keyboard_nav/workspace_comment_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/workspace_comment_navigation_policy.ts index 7fe70cead..ce4a65234 100644 --- a/packages/blockly/core/keyboard_nav/workspace_comment_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_comment_navigation_policy.ts @@ -4,10 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import {navigateStacks} from './block_navigation_policy.js'; +import {RenderedWorkspaceComment} from '../../comments/rendered_workspace_comment.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from an RenderedWorkspaceComment. @@ -38,21 +37,29 @@ export class WorkspaceCommentNavigationPolicy /** * Returns the next peer node of the given workspace comment. * - * @param current The workspace comment to find the following element of. - * @returns The next workspace comment or block stack, if any. + * @returns Null, as workspace comments do not have peers. */ - getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null { - return navigateStacks(current, 1); + getNextSibling(): IFocusableNode | null { + return null; } /** * Returns the previous peer node of the given workspace comment. * - * @param current The workspace comment to find the preceding element of. - * @returns The previous workspace comment or block stack, if any. + * @returns Null, as workspace comments do not have peers. */ - getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null { - return navigateStacks(current, -1); + getPreviousSibling(): IFocusableNode | null { + return null; + } + + /** + * Returns the row ID of the given workspace comment. + * + * @param current The workspace comment to retrieve the row ID of. + * @returns The row ID of the given workspace comment. + */ + getRowId(current: RenderedWorkspaceComment) { + return current.id; } /** diff --git a/packages/blockly/core/keyboard_nav/workspace_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_navigation_policy.ts similarity index 81% rename from packages/blockly/core/keyboard_nav/workspace_navigation_policy.ts rename to packages/blockly/core/keyboard_nav/navigation_policies/workspace_navigation_policy.ts index b671f8fe7..a2af54105 100644 --- a/packages/blockly/core/keyboard_nav/workspace_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/workspace_navigation_policy.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; -import {WorkspaceSvg} from '../workspace_svg.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {WorkspaceSvg} from '../../workspace_svg.js'; /** * Set of rules controlling keyboard navigation from a workspace. @@ -55,6 +55,16 @@ export class WorkspaceNavigationPolicy return null; } + /** + * Returns the row ID of the given workspace. + * + * @param current The workspace to retrieve the row ID of. + * @returns The row ID of the given workspace. + */ + getRowId(current: WorkspaceSvg) { + return current.id; + } + /** * Returns whether or not the given workspace can be navigated to. * diff --git a/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts new file mode 100644 index 000000000..9e2cd8bdc --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {IFocusableNode} from '../../blockly.js'; +import type {IFlyout} from '../../interfaces/i_flyout.js'; +import {FlyoutButtonNavigationPolicy} from '../navigation_policies/flyout_button_navigation_policy.js'; +import {FlyoutSeparatorNavigationPolicy} from '../navigation_policies/flyout_separator_navigation_policy.js'; +import {Navigator} from './navigator.js'; + +/** + * Navigator that handles keyboard navigation within a flyout. + */ +export class FlyoutNavigator extends Navigator { + constructor(protected flyout: IFlyout) { + super(); + this.rules.push( + new FlyoutButtonNavigationPolicy(), + new FlyoutSeparatorNavigationPolicy(), + ); + } + + /** + * Returns the toolbox when navigating to the left in a flyout. + */ + override getOutNode(): IFocusableNode | null { + const toolbox = this.flyout.targetWorkspace?.getToolbox(); + if (toolbox) return toolbox.getSelectedItem(); + + return null; + } + + /** + * Returns a list of top-level navigable flyout items. + */ + protected override getTopLevelItems(): IFocusableNode[] { + return this.flyout + .getContents() + .map((item) => item.getElement()) + .filter((element) => this.isNavigable(element)); + } + + /** + * Returns whether or not the given node is navigable. + * + * @param node A focusable node to check the navigability of. + * @returns True if the node is navigable, otherwise false. + */ + protected override isNavigable(node: IFocusableNode) { + return ( + super.isNavigable(node) && + this.flyout + .getContents() + .map((item): IFocusableNode => item.getElement()) + .includes(node) + ); + } +} diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts new file mode 100644 index 000000000..36b72ab7d --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -0,0 +1,526 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../../block_svg.js'; +import {ConnectionType} from '../../connection_type.js'; +import {Field} from '../../field.js'; +import {getFocusManager} from '../../focus_manager.js'; +import {Icon} from '../../icons/icon.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../../rendered_connection.js'; +import {BlockCommentNavigationPolicy} from '../navigation_policies/block_comment_navigation_policy.js'; +import {BlockNavigationPolicy} from '../navigation_policies/block_navigation_policy.js'; +import {CommentBarButtonNavigationPolicy} from '../navigation_policies/comment_bar_button_navigation_policy.js'; +import {CommentEditorNavigationPolicy} from '../navigation_policies/comment_editor_navigation_policy.js'; +import {ConnectionNavigationPolicy} from '../navigation_policies/connection_navigation_policy.js'; +import {FieldNavigationPolicy} from '../navigation_policies/field_navigation_policy.js'; +import {IconNavigationPolicy} from '../navigation_policies/icon_navigation_policy.js'; +import {WorkspaceCommentNavigationPolicy} from '../navigation_policies/workspace_comment_navigation_policy.js'; +import {WorkspaceNavigationPolicy} from '../navigation_policies/workspace_navigation_policy.js'; + +type RuleList = INavigationPolicy[]; + +/** + * Representation of the direction of travel within a navigation context. + */ +export enum NavigationDirection { + NEXT, + PREVIOUS, + IN, + OUT, +} + +/** + * Class responsible for determining where focus should move in response to + * keyboard navigation commands. + */ +export class Navigator { + /** + * Map from classes to a corresponding ruleset to handle navigation from + * instances of that class. + */ + protected rules: RuleList = [ + new BlockNavigationPolicy(), + new FieldNavigationPolicy(), + new ConnectionNavigationPolicy(), + new WorkspaceNavigationPolicy(), + new IconNavigationPolicy(), + new WorkspaceCommentNavigationPolicy(), + new CommentBarButtonNavigationPolicy(), + new BlockCommentNavigationPolicy(), + new CommentEditorNavigationPolicy(), + ]; + + /** Whether or not navigation loops around when reaching the end. */ + protected navigationLoops = false; + + /** + * Adds a navigation ruleset to this Navigator. + * + * @param policy A ruleset that determines where focus should move starting + * from an instance of its managed class. + */ + addNavigationPolicy(policy: INavigationPolicy) { + this.rules.push(policy); + } + + /** + * Returns the navigation ruleset associated with the given object instance's + * class. + * + * @param current An object to retrieve a navigation ruleset for. + * @returns The navigation ruleset of objects of the given object's class, or + * undefined if no ruleset has been registered for the object's class. + */ + private get( + current: IFocusableNode, + ): INavigationPolicy | undefined { + return this.rules.find((rule) => rule.isApplicable(current)); + } + + /** + * Returns the first child of the given object instance, if any. + * + * @param current The object to retrieve the first child of. + * @returns The first child node of the given object, if any. + */ + getFirstChild(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getFirstChild(current); + if (!result) return null; + if (!this.isNavigable(result)) { + return this.getFirstChild(result) || this.getNextSibling(result); + } + return result; + } + + /** + * Returns the parent of the given object instance, if any. + * + * @param current The object to retrieve the parent of. + * @returns The parent node of the given object, if any. + */ + getParent(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getParent(current); + if (!result) return null; + if (!this.isNavigable(result)) return this.getParent(result); + return result; + } + + /** + * Returns the next sibling of the given object instance, if any. + * + * @param current The object to retrieve the next sibling node of. + * @returns The next sibling node of the given object, if any. + */ + getNextSibling(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getNextSibling(current); + if (!result) return null; + if (!this.isNavigable(result)) { + return this.getNextSibling(result); + } + return result; + } + + /** + * Returns the previous sibling of the given object instance, if any. + * + * @param current The object to retrieve the previous sibling node of. + * @returns The previous sibling node of the given object, if any. + */ + getPreviousSibling(current: IFocusableNode): IFocusableNode | null { + const result = this.get(current)?.getPreviousSibling(current); + if (!result) return null; + if (!this.isNavigable(result)) { + return this.getPreviousSibling(result); + } + return result; + } + + /** + * Returns the previous node relative to the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The previous node, generally on the "row" visually above the + * specified node, or null if there is none. + */ + getPreviousNode( + node = getFocusManager().getFocusedNode(), + ): IFocusableNode | null { + if (!node) return null; + + let previous = this.getPreviousNodeImpl( + node, + node, + NavigationDirection.PREVIOUS, + ); + + // If the previous node is the root focusable tree or null, we need to + // traverse stacks of top-level items on the tree. Since we're going + // backwards to the previous stack, we actually want the last node in the + // stack (most adjacent to the current node) rather than the root of the + // stack. + if (!previous || (previous as any) === node.getFocusableTree()) { + const stackRoot = this.navigateStacks(node, -1); + if (!stackRoot) return null; + previous = this.getLastNodeInStack(stackRoot, node); + } + + return this.getLeftmostSibling(previous); + } + + /** + * Returns the node to the left of the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The node to the left of the given node, within the same visual + * "row" as the given node, or null if there is none. + */ + getOutNode(node = getFocusManager().getFocusedNode()): IFocusableNode | null { + // Special case: blocks and input value connections on blocks with external + // inputs should always navigate to the parent block, even though they're + // not necessarily on the same visual row. + const connection = + node instanceof BlockSvg + ? node.outputConnection?.targetConnection + : node instanceof RenderedConnection && + node.type === ConnectionType.INPUT_VALUE + ? node + : null; + if ( + connection && + !connection.getSourceBlock().getInputsInline() && + connection !== connection.getSourceBlock().inputList[0].connection + ) { + return connection.getSourceBlock(); + } + + return this.getPreviousNodeImpl(node, node, NavigationDirection.OUT); + } + + /** + * Returns next node relative to the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The next node, generally on the "row" visually below the + * specified node, or null if there is none. + */ + getNextNode( + node = getFocusManager().getFocusedNode(), + ): IFocusableNode | null { + const next = this.getNextNodeImpl(node, node, NavigationDirection.NEXT); + + if (node && next === null) { + return this.navigateStacks(node, 1); + } + + return next; + } + + /** + * Returns the node to the right of the given node. + * + * @param node The node to navigate relative to, defaults to the currently + * focused node. + * @returns The node to the right of the given node, within the same visual + * "row" as the given node, or null if there is none. + */ + getInNode(node = getFocusManager().getFocusedNode()): IFocusableNode | null { + return this.getNextNodeImpl(node, node, NavigationDirection.IN); + } + + /** + * Returns the previous sibling/parent node relative to the given node. + * + * @param startNode The node that navigation is starting from. + * @param node The node to navigate relative to. + * @param direction The direction to navigate, either OUT or PREVIOUS. + * @param visitedNodes Set of already-visited nodes used to avoid cycles, + * should not be specified by the caller. + * @returns The previous sibling/parent node, or null if there is none or a + * node was not provided. + */ + private getPreviousNodeImpl( + startNode: IFocusableNode | null, + node: IFocusableNode | null, + direction: NavigationDirection.PREVIOUS | NavigationDirection.OUT, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if (!node || !startNode || visitedNodes.has(node)) { + return null; + } + + const newNode = + this.getRightMostChild(this.getPreviousSibling(node), node) || + this.getParent(node); + + if (newNode && this.transitionAllowed(startNode, newNode, direction)) { + return newNode; + } + + if (newNode) { + visitedNodes.add(node); + return this.getPreviousNodeImpl( + startNode, + newNode, + direction, + visitedNodes, + ); + } + return null; + } + + /** + * Returns the next sibling/child node relative to the given node. + * + * @param startNode The node that navigation is starting from. + * @param node The node to navigate relative to. + * @param direction The direction to navigate, either IN or NEXT. + * @param visitedNodes Set of already-visited nodes used to avoid cycles, + * should not be specified by the caller. + * @returns The next sibling/child node, or null if there is none or a + * node was not provided. + */ + private getNextNodeImpl( + startNode: IFocusableNode | null, + node: IFocusableNode | null, + direction: NavigationDirection.NEXT | NavigationDirection.IN, + visitedNodes: Set = new Set(), + ): IFocusableNode | null { + if (!node || !startNode || visitedNodes.has(node)) { + return null; + } + + let newNode = this.getFirstChild(node) || this.getNextSibling(node); + + let target = node; + while (target && !newNode) { + const parent = this.getParent(target); + if (!parent) break; + newNode = this.getNextSibling(parent); + target = parent; + } + + if (newNode && this.transitionAllowed(startNode, newNode, direction)) { + return newNode; + } + if (newNode) { + visitedNodes.add(node); + return this.getNextNodeImpl(startNode, newNode, direction, visitedNodes); + } + + return null; + } + + private getRightMostChild( + node: IFocusableNode | null, + stopIfFound?: IFocusableNode, + ): IFocusableNode | null { + if (!node) return node; + let newNode = this.getFirstChild(node); + if (!newNode || newNode === stopIfFound) return node; + for ( + let nextNode: IFocusableNode | null = newNode; + nextNode; + nextNode = this.getNextSibling(newNode) + ) { + if (nextNode === stopIfFound) break; + newNode = nextNode; + } + return this.getRightMostChild(newNode, stopIfFound); + } + + /** + * 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; + } + + /** + * Get the first navigable node on the workspace, or null if none exist. + * + * @returns The first navigable node on the workspace, or null. + */ + getFirstNode(): IFocusableNode | null { + const root = getFocusManager().getFocusedTree()?.getRootFocusableNode(); + if (!root) return null; + + return this.getFirstChild(root); + } + + /** + * Get the last navigable node on the workspace, or null if none exist. + * + * @returns The last navigable node on the workspace, or null. + */ + getLastNode(): IFocusableNode | null { + const first = this.getFirstNode(); + const oldLooping = this.getNavigationLoops(); + this.setNavigationLoops(true); + const lastNode = this.getPreviousNode(first); + this.setNavigationLoops(oldLooping); + return lastNode; + } + + /** + * Determines whether navigation is allowed between two nodes. + * + * @param current The starting node for proposed navigation. + * @param candidate The proposed destination node. + * @param direction The direction in which the user is navigating. + * @returns True if navigation should be allowed to proceed, or false to find + * a different candidate. + */ + protected transitionAllowed( + current: IFocusableNode, + candidate: IFocusableNode, + direction: NavigationDirection, + ) { + switch (direction) { + case NavigationDirection.IN: + case NavigationDirection.OUT: + return this.getRowId(current) === this.getRowId(candidate); + case NavigationDirection.NEXT: + case NavigationDirection.PREVIOUS: + return this.getRowId(current) !== this.getRowId(candidate); + } + } + + /** + * Returns the leftmost node in the same row as the given node. + * + * @param node The node to find the leftmost sibling of. + * @returns The leftmost sibling of the given node in the same row. + */ + private getLeftmostSibling(node: IFocusableNode | null) { + if (!node) return null; + + let left = node; + let temp; + while ( + (temp = this.getPreviousNodeImpl(left, left, NavigationDirection.OUT)) + ) { + left = temp; + } + + return left; + } + + /** + * Returns the last node in a stack of blocks or other top-level workspace + * entity. + * + * @param stackRoot A top-level item to get the last node of. + * @param stopIfFound A sentinel node that terminates traversal if + * encountered; typically the root node of the next stack. + * @returns The last node in the given stack. + */ + private getLastNodeInStack( + stackRoot: IFocusableNode, + stopIfFound: IFocusableNode, + ) { + let target = stackRoot; + let temp; + while ( + (temp = this.getNextNodeImpl(target, target, NavigationDirection.NEXT)) && + temp !== stopIfFound + ) { + target = temp; + } + + return target; + } + + private getRowId(node: IFocusableNode) { + return this.get(node)?.getRowId(node); + } + + /** + * Returns the next/previous stack relative to the given element's stack. + * + * @param current The element whose stack will be navigated relative to. + * @param delta The difference in index to navigate; positive values navigate + * to the nth next stack, while negative values navigate to the nth + * previous stack. + * @returns The first element in the stack offset by `delta` relative to the + * current element's stack, or the last element in the stack offset by + * `delta` relative to the current element's stack when navigating backwards. + */ + protected navigateStacks(current: IFocusableNode, delta: number) { + const stacks = this.getTopLevelItems(current); + const root = + this.getSourceBlockFromNode(current)?.getRootBlock() ?? current; + const currentIndex = stacks.indexOf(root); + const targetIndex = currentIndex + delta; + let result: IFocusableNode | null = null; + if (targetIndex >= 0 && targetIndex < stacks.length) { + result = stacks[targetIndex]; + } else if (targetIndex < 0 && this.getNavigationLoops()) { + result = stacks[stacks.length - 1]; + } else if (targetIndex >= stacks.length && this.getNavigationLoops()) { + result = stacks[0]; + } + + return result; + } + + /** + * Returns a list of all top-level focusable items on the given node's + * focusable tree. + * + * @param current The node whose root focusable tree to retrieve the top-level + * items of. + * @returns A list of all top-level items on the given node's parent tree. + */ + protected getTopLevelItems(current: IFocusableNode): IFocusableNode[] { + const workspace = current.getFocusableTree(); + return (workspace as any).getTopBoundedElements(true); + } + + /** + * Returns whether or not the given node is navigable. + * + * @param node A focusable node to check the navigability of. + * @returns True if the node is navigable, otherwise false. + */ + protected isNavigable(node: IFocusableNode) { + return this.get(node)?.isNavigable(node); + } + + /** + * Returns the block that the given node is a child of. + * + * @returns The parent block of the node if any, otherwise null. + */ + getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null { + if (node instanceof BlockSvg) { + return node; + } else if (node instanceof Field) { + return node.getSourceBlock() as BlockSvg; + } else if (node instanceof RenderedConnection) { + return node.getSourceBlock(); + } else if (node instanceof Icon) { + return node.getSourceBlock() as BlockSvg; + } + + return null; + } +} diff --git a/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts new file mode 100644 index 000000000..8b7238cf5 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {getFocusManager} from '../../focus_manager.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import {isSelectableToolboxItem} from '../../interfaces/i_selectable_toolbox_item.js'; +import type {IToolbox} from '../../interfaces/i_toolbox.js'; +import {ToolboxItemNavigationPolicy} from '../navigation_policies/toolbox_item_navigation_policy.js'; +import {Navigator} from './navigator.js'; + +/** + * Navigator that handles keyboard navigation within a toolbox. + */ +export class ToolboxNavigator extends Navigator { + constructor(protected toolbox: IToolbox) { + super(); + this.rules = [new ToolboxItemNavigationPolicy()]; + } + + /** + * Returns the flyout's first item when navigating to the right in a toolbox + * from a toolbox item that has a flyout. + */ + override getInNode( + current = getFocusManager().getFocusedNode(), + ): IFocusableNode | null { + if (isSelectableToolboxItem(current) && !current.getContents().length) { + return null; + } + + return ( + this.toolbox.getFlyout()?.getWorkspace().getRestoredFocusableNode(null) ?? + null + ); + } + + /** + * Returns a list of all toolbox items. + */ + protected override getTopLevelItems(): IFocusableNode[] { + return this.toolbox.getToolboxItems(); + } +} diff --git a/packages/blockly/core/marker_manager.ts b/packages/blockly/core/marker_manager.ts deleted file mode 100644 index e94aa3e96..000000000 --- a/packages/blockly/core/marker_manager.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Object in charge of managing markers and the cursor. - * - * @class - */ -// Former goog.module ID: Blockly.MarkerManager - -import {LineCursor} from './keyboard_nav/line_cursor.js'; -import type {Marker} from './keyboard_nav/marker.js'; -import type {WorkspaceSvg} from './workspace_svg.js'; - -/** - * Class to manage the multiple markers and the cursor on a workspace. - */ -export class MarkerManager { - /** The name of the local marker. */ - static readonly LOCAL_MARKER = 'local_marker_1'; - - /** The cursor. */ - private cursor: LineCursor; - - /** The map of markers for the workspace. */ - private markers = new Map(); - - /** - * @param workspace The workspace for the marker manager. - * @internal - */ - constructor(private readonly workspace: WorkspaceSvg) { - this.cursor = new LineCursor(this.workspace); - } - - /** - * Register the marker by adding it to the map of markers. - * - * @param id A unique identifier for the marker. - * @param marker The marker to register. - */ - registerMarker(id: string, marker: Marker) { - if (this.markers.has(id)) { - this.unregisterMarker(id); - } - this.markers.set(id, marker); - } - - /** - * Unregister the marker by removing it from the map of markers. - * - * @param id The ID of the marker to unregister. - */ - unregisterMarker(id: string) { - const marker = this.markers.get(id); - if (marker) { - marker.dispose(); - this.markers.delete(id); - } else { - throw Error( - 'Marker with ID ' + - id + - ' does not exist. ' + - 'Can only unregister markers that exist.', - ); - } - } - - /** - * Get the cursor for the workspace. - * - * @returns The cursor for this workspace. - */ - getCursor(): LineCursor { - return this.cursor; - } - - /** - * Get a single marker that corresponds to the given ID. - * - * @param id A unique identifier for the marker. - * @returns The marker that corresponds to the given ID, or null if none - * exists. - */ - getMarker(id: string): Marker | null { - return this.markers.get(id) || null; - } - - /** - * Sets the cursor and initializes the drawer for use with keyboard - * navigation. - * - * @param cursor The cursor used to move around this workspace. - */ - setCursor(cursor: LineCursor) { - this.cursor = cursor; - } - - /** - * Dispose of the marker manager. - * Go through and delete all markers associated with this marker manager. - * - * @internal - */ - dispose() { - const markerIds = Object.keys(this.markers); - for (let i = 0, markerId; (markerId = markerIds[i]); i++) { - this.unregisterMarker(markerId); - } - this.markers.clear(); - this.cursor.dispose(); - } -} diff --git a/packages/blockly/core/navigator.ts b/packages/blockly/core/navigator.ts deleted file mode 100644 index 9c7c22f59..000000000 --- a/packages/blockly/core/navigator.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {IFocusableNode} from './interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; -import {BlockCommentNavigationPolicy} from './keyboard_nav/block_comment_navigation_policy.js'; -import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js'; -import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js'; -import {CommentEditorNavigationPolicy} from './keyboard_nav/comment_editor_navigation_policy.js'; -import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js'; -import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js'; -import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js'; -import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js'; -import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js'; - -type RuleList = INavigationPolicy[]; - -/** - * Class responsible for determining where focus should move in response to - * keyboard navigation commands. - */ -export class Navigator { - /** - * Map from classes to a corresponding ruleset to handle navigation from - * instances of that class. - */ - protected rules: RuleList = [ - new BlockNavigationPolicy(), - new FieldNavigationPolicy(), - new ConnectionNavigationPolicy(), - new WorkspaceNavigationPolicy(), - new IconNavigationPolicy(), - new WorkspaceCommentNavigationPolicy(), - new CommentBarButtonNavigationPolicy(), - new BlockCommentNavigationPolicy(), - new CommentEditorNavigationPolicy(), - ]; - - /** - * Adds a navigation ruleset to this Navigator. - * - * @param policy A ruleset that determines where focus should move starting - * from an instance of its managed class. - */ - addNavigationPolicy(policy: INavigationPolicy) { - this.rules.push(policy); - } - - /** - * Returns the navigation ruleset associated with the given object instance's - * class. - * - * @param current An object to retrieve a navigation ruleset for. - * @returns The navigation ruleset of objects of the given object's class, or - * undefined if no ruleset has been registered for the object's class. - */ - private get( - current: IFocusableNode, - ): INavigationPolicy | undefined { - return this.rules.find((rule) => rule.isApplicable(current)); - } - - /** - * Returns the first child of the given object instance, if any. - * - * @param current The object to retrieve the first child of. - * @returns The first child node of the given object, if any. - */ - getFirstChild(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getFirstChild(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) { - return this.getFirstChild(result) || this.getNextSibling(result); - } - return result; - } - - /** - * Returns the parent of the given object instance, if any. - * - * @param current The object to retrieve the parent of. - * @returns The parent node of the given object, if any. - */ - getParent(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getParent(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) return this.getParent(result); - return result; - } - - /** - * Returns the next sibling of the given object instance, if any. - * - * @param current The object to retrieve the next sibling node of. - * @returns The next sibling node of the given object, if any. - */ - getNextSibling(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getNextSibling(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) { - return this.getNextSibling(result); - } - return result; - } - - /** - * Returns the previous sibling of the given object instance, if any. - * - * @param current The object to retrieve the previous sibling node of. - * @returns The previous sibling node of the given object, if any. - */ - getPreviousSibling(current: IFocusableNode): IFocusableNode | null { - const result = this.get(current)?.getPreviousSibling(current); - if (!result) return null; - if (!this.get(result)?.isNavigable(result)) { - return this.getPreviousSibling(result); - } - return result; - } -} diff --git a/packages/blockly/core/registry.ts b/packages/blockly/core/registry.ts index 4980a5594..d851d33f2 100644 --- a/packages/blockly/core/registry.ts +++ b/packages/blockly/core/registry.ts @@ -26,7 +26,6 @@ import type { IVariableModelStatic, IVariableState, } from './interfaces/i_variable_model.js'; -import type {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Options} from './options.js'; import type {Renderer} from './renderers/common/renderer.js'; import type {Theme} from './theme.js'; @@ -78,8 +77,6 @@ export class Type<_T> { 'connectionPreviewer', ); - static CURSOR = new Type('cursor'); - static EVENT = new Type('event'); static FIELD = new Type('field'); diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 4d5a0c43e..c904d04ee 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -39,6 +39,7 @@ export enum names { REDO = 'redo', MENU = 'menu', FOCUS_WORKSPACE = 'focus_workspace', + FOCUS_TOOLBOX = 'focus_toolbox', START_MOVE = 'start_move', START_MOVE_STACK = 'start_move_stack', FINISH_MOVE = 'finish_move', @@ -47,6 +48,10 @@ export enum names { MOVE_DOWN = 'move_down', MOVE_LEFT = 'move_left', MOVE_RIGHT = 'move_right', + NAVIGATE_RIGHT = 'right', + NAVIGATE_LEFT = 'left', + NAVIGATE_UP = 'up', + NAVIGATE_DOWN = 'down', DISCONNECT = 'disconnect', } @@ -396,7 +401,11 @@ export function registerMovementShortcuts() { ): IDraggable | undefined => { const node = getFocusManager().getFocusedNode(); if (isDraggable(node)) return node; - return workspace.getCursor().getSourceBlock() ?? undefined; + return ( + workspace + .getNavigator() + .getSourceBlockFromNode(getFocusManager().getFocusedNode()) ?? undefined + ); }; const shiftM = ShortcutRegistry.registry.createSerializedKey(KeyCodes.M, [ @@ -528,7 +537,8 @@ export function registerShowContextMenu() { preconditionFn: (workspace) => { return !workspace.isDragging(); }, - callback: (_workspace, e) => { + callback: (workspace, e) => { + keyboardNavigationController.setIsActive(true); const target = getFocusManager().getFocusedNode(); if (hasContextMenu(target)) { target.showContextMenu(e); @@ -543,6 +553,88 @@ export function registerShowContextMenu() { ShortcutRegistry.registry.register(contextMenuShortcut); } +/** + * Registers keyboard shortcuts to navigate around the Blockly interface. + */ +export function registerArrowNavigation() { + const shortcuts: { + [name: string]: ShortcutRegistry.KeyboardShortcut; + } = { + /** Go to the next location to the right. */ + right: { + name: names.NAVIGATE_RIGHT, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const node = workspace.RTL + ? getFocusManager().getFocusedTree()?.getNavigator().getOutNode() + : getFocusManager().getFocusedTree()?.getNavigator().getInNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.RIGHT], + allowCollision: true, + }, + + /** Go to the next location to the left. */ + left: { + name: names.NAVIGATE_LEFT, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const node = workspace.RTL + ? getFocusManager().getFocusedTree()?.getNavigator().getInNode() + : getFocusManager().getFocusedTree()?.getNavigator().getOutNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.LEFT], + allowCollision: true, + }, + + /** Go down to the next location. */ + down: { + name: names.NAVIGATE_DOWN, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: () => { + keyboardNavigationController.setIsActive(true); + const node = getFocusManager() + .getFocusedTree() + ?.getNavigator() + .getNextNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.DOWN], + allowCollision: true, + }, + /** Go up to the previous location. */ + up: { + name: names.NAVIGATE_UP, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: () => { + keyboardNavigationController.setIsActive(true); + const node = getFocusManager() + .getFocusedTree() + ?.getNavigator() + .getPreviousNode(); + if (!node) return false; + getFocusManager().focusNode(node); + return true; + }, + keyCodes: [KeyCodes.UP], + allowCollision: true, + }, + }; + + for (const shortcut of Object.values(shortcuts)) { + ShortcutRegistry.registry.register(shortcut); + } +} + /** * Registers keyboard shortcut to focus the workspace. */ @@ -557,7 +649,7 @@ export function registerFocusWorkspace() { return workspace.getRootWorkspace() ?? workspace; }; - const contextMenuShortcut: KeyboardShortcut = { + const focusWorkspaceShortcut: KeyboardShortcut = { name: names.FOCUS_WORKSPACE, preconditionFn: (workspace) => !workspace.isDragging(), callback: (workspace) => { @@ -567,7 +659,34 @@ export function registerFocusWorkspace() { }, keyCodes: [KeyCodes.W], }; - ShortcutRegistry.registry.register(contextMenuShortcut); + ShortcutRegistry.registry.register(focusWorkspaceShortcut); +} + +/** + * Registers keyboard shortcut to focus the toolbox. + */ +export function registerFocusToolbox() { + const focusToolboxShortcut: KeyboardShortcut = { + name: names.FOCUS_TOOLBOX, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + const toolbox = workspace.getToolbox(); + if (toolbox) { + keyboardNavigationController.setIsActive(true); + getFocusManager().focusTree(toolbox); + return true; + } else { + const flyout = workspace.getFlyout(); + if (!flyout) return false; + + keyboardNavigationController.setIsActive(true); + getFocusManager().focusTree(flyout.getWorkspace()); + return true; + } + }, + keyCodes: [KeyCodes.T], + }; + ShortcutRegistry.registry.register(focusToolboxShortcut); } /** @@ -621,6 +740,8 @@ export function registerKeyboardNavigationShortcuts() { registerShowContextMenu(); registerMovementShortcuts(); registerFocusWorkspace(); + registerFocusToolbox(); + registerArrowNavigation(); registerDisconnectBlock(); } diff --git a/packages/blockly/core/toolbox/category.ts b/packages/blockly/core/toolbox/category.ts index dd42a549f..72acdee73 100644 --- a/packages/blockly/core/toolbox/category.ts +++ b/packages/blockly/core/toolbox/category.ts @@ -593,6 +593,16 @@ export class ToolboxCategory return this.htmlDiv_; } + /** + * Handles this toolbox category gaining focus by informing its parent + * toolbox that it has been selected. + */ + override onNodeFocus(): void { + if (this.getParentToolbox().getSelectedItem() !== this) { + this.getParentToolbox().setSelectedItem(this); + } + } + /** * Gets the contents of the category. These are items that are meant to be * displayed in the flyout. diff --git a/packages/blockly/core/toolbox/separator.ts b/packages/blockly/core/toolbox/separator.ts index cd5ed245a..bcf66a16b 100644 --- a/packages/blockly/core/toolbox/separator.ts +++ b/packages/blockly/core/toolbox/separator.ts @@ -73,6 +73,13 @@ export class ToolboxSeparator extends ToolboxItem { override dispose() { dom.removeNode(this.htmlDiv as HTMLDivElement); } + + /** + * Prevents separator toolbox items from gaining focus. + */ + override canBeFocused(): boolean { + return false; + } } export namespace ToolboxSeparator { diff --git a/packages/blockly/core/toolbox/toolbox.ts b/packages/blockly/core/toolbox/toolbox.ts index 6f4daf4ed..28861f231 100644 --- a/packages/blockly/core/toolbox/toolbox.ts +++ b/packages/blockly/core/toolbox/toolbox.ts @@ -23,7 +23,10 @@ import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import {getFocusManager} from '../focus_manager.js'; import {type IAutoHideable} from '../interfaces/i_autohideable.js'; -import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js'; +import { + isCollapsibleToolboxItem, + type ICollapsibleToolboxItem, +} from '../interfaces/i_collapsible_toolbox_item.js'; import {isDeletable} from '../interfaces/i_deletable.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IFlyout} from '../interfaces/i_flyout.js'; @@ -35,6 +38,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 {ToolboxNavigator} from '../keyboard_nav/navigators/toolbox_navigator.js'; import * as registry from '../registry.js'; import type {KeyboardShortcut} from '../shortcut_registry.js'; import * as Touch from '../touch.js'; @@ -111,6 +115,9 @@ export class Toolbox /** Whether the mouse is currently being clicked. */ private mouseDown = false; + /** Object used by keyboard navigation to move focus in this toolbox. */ + private navigator = new ToolboxNavigator(this); + /** @param workspace The workspace in which to create new blocks. */ constructor(workspace: WorkspaceSvg) { super(); @@ -300,40 +307,17 @@ export class Toolbox protected onKeyDown_(e: KeyboardEvent) { let handled = false; switch (e.key) { - case 'ArrowDown': - handled = this.selectNext(); - break; - case 'ArrowUp': - handled = this.selectPrevious(); - break; case 'ArrowLeft': - handled = this.selectParent(); + handled = this.toggleSelectedItem(false); break; case 'ArrowRight': - handled = this.selectChild(); + handled = this.toggleSelectedItem(true); break; - case 'Enter': - case ' ': - if (this.selectedItem_ && this.selectedItem_.isCollapsible()) { - const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem; - collapsibleItem.toggleExpanded(); - handled = true; - } - break; - default: - handled = false; - break; - } - if (!handled && this.selectedItem_) { - // TODO(#6097): Figure out who implements onKeyDown and which interface it - // should be part of. - if ((this.selectedItem_ as any).onKeyDown) { - handled = (this.selectedItem_ as any).onKeyDown(e); - } } if (handled) { e.preventDefault(); + e.stopPropagation(); } } @@ -976,99 +960,21 @@ export class Toolbox } /** - * Closes the current item if it is expanded, or selects the parent. + * Sets the currently selected item's expansion state, if possible. * - * @returns True if a parent category was selected, false otherwise. + * @param expanded True to expand the item or false to collapse it. + * @returns True if the selected item's expansion state was updated. */ - private selectParent(): boolean { - if (!this.selectedItem_) { - return false; - } - + private toggleSelectedItem(expanded: boolean): boolean { if ( + isCollapsibleToolboxItem(this.selectedItem_) && this.selectedItem_.isCollapsible() && - (this.selectedItem_ as ICollapsibleToolboxItem).isExpanded() + this.selectedItem_.isExpanded() !== expanded ) { - const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem; - collapsibleItem.toggleExpanded(); - return true; - } else if ( - this.selectedItem_.getParent() && - this.selectedItem_.getParent()!.isSelectable() - ) { - this.setSelectedItem(this.selectedItem_.getParent()); + this.selectedItem_.toggleExpanded(); return true; } - return false; - } - /** - * Selects the first child of the currently selected item, or nothing if the - * toolbox item has no children. - * - * @returns True if a child category was selected, false otherwise. - */ - private selectChild(): boolean { - if (!this.selectedItem_ || !this.selectedItem_.isCollapsible()) { - return false; - } - const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem; - if (!collapsibleItem.isExpanded()) { - collapsibleItem.toggleExpanded(); - return true; - } else { - this.selectNext(); - return true; - } - } - - /** - * Selects the next visible toolbox item. - * - * @returns True if a next category was selected, false otherwise. - */ - private selectNext(): boolean { - if (!this.selectedItem_) { - return false; - } - - const items = [...this.contents.values()]; - let nextItemIdx = items.indexOf(this.selectedItem_) + 1; - if (nextItemIdx > -1 && nextItemIdx < items.length) { - let nextItem = items[nextItemIdx]; - while (nextItem && !nextItem.isSelectable()) { - nextItem = items[++nextItemIdx]; - } - if (nextItem && nextItem.isSelectable()) { - this.setSelectedItem(nextItem); - return true; - } - } - return false; - } - - /** - * Selects the previous visible toolbox item. - * - * @returns True if a previous category was selected, false otherwise. - */ - private selectPrevious(): boolean { - if (!this.selectedItem_) { - return false; - } - - const items = [...this.contents.values()]; - let prevItemIdx = items.indexOf(this.selectedItem_) - 1; - if (prevItemIdx > -1 && prevItemIdx < items.length) { - let prevItem = items[prevItemIdx]; - while (prevItem && !prevItem.isSelectable()) { - prevItem = items[--prevItemIdx]; - } - if (prevItem && prevItem.isSelectable()) { - this.setSelectedItem(prevItem); - return true; - } - } return false; } @@ -1167,6 +1073,14 @@ export class Toolbox this.autoHide(false); } } + + /** + * Returns the Navigator instance to use to move between items in this + * toolbox. + */ + getNavigator(): ToolboxNavigator { + return this.navigator; + } } /** CSS for Toolbox. See css.js for use. */ diff --git a/packages/blockly/core/toolbox/toolbox_item.ts b/packages/blockly/core/toolbox/toolbox_item.ts index 9fc5c160d..92c372136 100644 --- a/packages/blockly/core/toolbox/toolbox_item.ts +++ b/packages/blockly/core/toolbox/toolbox_item.ts @@ -177,5 +177,12 @@ export class ToolboxItem implements IToolboxItem { canBeFocused(): boolean { return true; } + + /** + * Returns the toolbox this toolbox item belongs to. + */ + getParentToolbox(): IToolbox { + return this.parentToolbox_; + } } // nop by default diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 657a94d46..e36668ced 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -60,12 +60,9 @@ import {hasBubble} from './interfaces/i_has_bubble.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; import type {IToolbox} from './interfaces/i_toolbox.js'; import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; -import type {LineCursor} from './keyboard_nav/line_cursor.js'; -import type {Marker} from './keyboard_nav/marker.js'; +import {Navigator} from './keyboard_nav/navigators/navigator.js'; import {LayerManager} from './layer_manager.js'; -import {MarkerManager} from './marker_manager.js'; import {Msg} from './msg.js'; -import {Navigator} from './navigator.js'; import {Options} from './options.js'; import * as Procedures from './procedures.js'; import * as registry from './registry.js'; @@ -296,7 +293,6 @@ export class WorkspaceSvg private readonly highlightedBlocks: BlockSvg[] = []; private audioManager: WorkspaceAudio; private grid: Grid | null; - private markerManager: MarkerManager; /** * Map from function names to callbacks, for deciding what to do when a @@ -318,9 +314,6 @@ export class WorkspaceSvg /** Cached parent SVG. */ private cachedParentSvg: SVGElement | null = null; - /** True if keyboard accessibility mode is on, false otherwise. */ - keyboardAccessibilityMode = false; - /** The list of top-level bounded elements on the workspace. */ private topBoundedElements: IBoundedElement[] = []; @@ -384,9 +377,6 @@ export class WorkspaceSvg ? new Grid(this.options.gridPattern, options.gridOptions) : null; - /** Manager in charge of markers and cursors. */ - this.markerManager = new MarkerManager(this); - if (Variables && Variables.internalFlyoutCategory) { this.registerToolboxCategoryCallback( Variables.CATEGORY_NAME, @@ -432,15 +422,6 @@ export class WorkspaceSvg this.cachedParentSvgSize = new Size(0, 0); } - /** - * Get the marker manager for this workspace. - * - * @returns The marker manager. - */ - getMarkerManager(): MarkerManager { - return this.markerManager; - } - /** * Gets the metrics manager for this workspace. * @@ -470,27 +451,6 @@ export class WorkspaceSvg return this.componentManager; } - /** - * Get the marker with the given ID. - * - * @param id The ID of the marker. - * @returns The marker with the given ID or null if no marker with the given - * ID exists. - * @internal - */ - getMarker(id: string): Marker | null { - return this.markerManager.getMarker(id); - } - - /** - * The cursor for this workspace. - * - * @returns The cursor for the workspace. - */ - getCursor(): LineCursor { - return this.markerManager.getCursor(); - } - /** * Get the block renderer attached to this workspace. * @@ -834,12 +794,6 @@ export class WorkspaceSvg this.grid.update(this.scale); } this.recordDragTargets(); - const CursorClass = registry.getClassFromOptions( - registry.Type.CURSOR, - this.options, - ); - - if (CursorClass) this.markerManager.setCursor(new CursorClass(this)); const isParentWorkspace = this.options.parentWorkspace === null; this.renderer.createDom( @@ -896,7 +850,6 @@ export class WorkspaceSvg } this.renderer.dispose(); - this.markerManager.dispose(); super.dispose(); diff --git a/packages/blockly/tests/mocha/cursor_test.js b/packages/blockly/tests/mocha/cursor_test.js deleted file mode 100644 index 02426ae26..000000000 --- a/packages/blockly/tests/mocha/cursor_test.js +++ /dev/null @@ -1,922 +0,0 @@ -/** - * @license - * Copyright 2019 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {assert} from '../../node_modules/chai/index.js'; -import {createRenderedBlock} from './test_helpers/block_definitions.js'; -import { - sharedTestSetup, - sharedTestTeardown, -} from './test_helpers/setup_teardown.js'; - -suite('Cursor', function () { - suite('Movement', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'input_statement', - 'message0': '%1 %2 %3 %4', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME1', - 'text': 'default', - }, - { - 'type': 'field_input', - 'name': 'NAME2', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'NAME3', - }, - { - 'type': 'input_statement', - 'name': 'NAME4', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'field_input', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - ], - 'output': null, - 'colour': 230, - 'tooltip': '', - 'helpUrl': '', - }, - { - 'type': 'multi_statement_input', - 'message0': '%1 %2', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'FIRST', - }, - { - 'type': 'input_statement', - 'name': 'SECOND', - }, - ], - }, - { - 'type': 'simple_statement', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - const blockA = createRenderedBlock(this.workspace, 'input_statement'); - const blockB = createRenderedBlock(this.workspace, 'input_statement'); - const blockC = createRenderedBlock(this.workspace, 'input_statement'); - const blockD = createRenderedBlock(this.workspace, 'input_statement'); - const blockE = createRenderedBlock(this.workspace, 'field_input'); - - blockA.nextConnection.connect(blockB.previousConnection); - blockA.inputList[0].connection.connect(blockE.outputConnection); - blockB.inputList[1].connection.connect(blockC.previousConnection); - this.cursor.drawer = null; - this.blocks = { - A: blockA, - B: blockB, - C: blockC, - D: blockD, - E: blockE, - }; - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - - test('Next - From a Previous connection go to the next block', function () { - const prevNode = this.blocks.A.previousConnection; - this.cursor.setCurNode(prevNode); - this.cursor.next(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.A); - }); - test('Next - From a block go to its statement input', function () { - const prevNode = this.blocks.B; - this.cursor.setCurNode(prevNode); - this.cursor.next(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.C); - }); - - test('In - From field to attached input connection', function () { - const fieldBlock = this.blocks.E; - const fieldNode = this.blocks.A.getField('NAME2'); - this.cursor.setCurNode(fieldNode); - this.cursor.in(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, fieldBlock); - }); - - test('Prev - From previous connection does 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); - }); - - test('Prev - From first block loop to last block', 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); - }); - - test('Out - From field does not skip over block node', function () { - const field = this.blocks.E.inputList[0].fieldRow[0]; - const fieldNode = field; - this.cursor.setCurNode(fieldNode); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.E); - }); - - test('Out - From first connection loop to last next connection', function () { - const prevConnection = this.blocks.A.previousConnection; - const prevConnectionNode = prevConnection; - this.cursor.setCurNode(prevConnectionNode); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.D.nextConnection); - }); - }); - - suite('Multiple statement inputs', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'multi_statement_input', - 'message0': '%1 %2', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'FIRST', - }, - { - 'type': 'input_statement', - 'name': 'SECOND', - }, - ], - }, - { - 'type': 'simple_statement', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'NAME', - 'text': 'default', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - - this.multiStatement1 = createRenderedBlock( - this.workspace, - 'multi_statement_input', - ); - this.multiStatement2 = createRenderedBlock( - this.workspace, - 'multi_statement_input', - ); - this.firstStatement = createRenderedBlock( - this.workspace, - 'simple_statement', - ); - this.secondStatement = createRenderedBlock( - this.workspace, - 'simple_statement', - ); - this.thirdStatement = createRenderedBlock( - this.workspace, - 'simple_statement', - ); - this.fourthStatement = createRenderedBlock( - this.workspace, - 'simple_statement', - ); - this.multiStatement1 - .getInput('FIRST') - .connection.connect(this.firstStatement.previousConnection); - this.firstStatement.nextConnection.connect( - this.secondStatement.previousConnection, - ); - this.multiStatement1 - .getInput('SECOND') - .connection.connect(this.thirdStatement.previousConnection); - this.multiStatement2 - .getInput('FIRST') - .connection.connect(this.fourthStatement.previousConnection); - }); - - teardown(function () { - sharedTestTeardown.call(this); - }); - - test('In - from field in nested statement block to next nested statement block', function () { - this.cursor.setCurNode(this.secondStatement.getField('NAME')); - 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(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.multiStatement2); - }); - - test('Out - from nested statement block to last field of previous nested statement block', function () { - this.cursor.setCurNode(this.thirdStatement); - this.cursor.out(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.secondStatement.getField('NAME')); - }); - - 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(); - const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.thirdStatement.getField('NAME')); - }); - }); - - suite('Searching', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'empty_block', - 'message0': '', - }, - { - 'type': 'stack_block', - 'message0': '', - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'row_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_value', - 'name': 'INPUT', - }, - ], - 'output': null, - }, - { - 'type': 'statement_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'STATEMENT', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'c_hat_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'input_statement', - 'name': 'STATEMENT', - }, - ], - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - suite('one empty block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('empty_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); - }); - }); - - suite('one stack block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('stack_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); - }); - }); - - suite('one row block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('row_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA.inputList[0].connection); - }); - }); - suite('one c-hat block', function () { - setup(function () { - this.blockA = this.workspace.newBlock('c_hat_block'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - assert.equal(node, this.blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); - }); - }); - - suite('multiblock stack', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'B', - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - const blockA = this.workspace.getBlockById('A'); - assert.equal(node, blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - const blockB = this.workspace.getBlockById('B'); - assert.equal(node, blockB); - }); - }); - - suite('multiblock row', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'row_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'inputs': { - 'INPUT': { - 'block': { - 'type': 'row_block', - 'id': 'B', - }, - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - const blockA = this.workspace.getBlockById('A'); - assert.equal(node, blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - const blockB = this.workspace.getBlockById('B'); - assert.equal(node, blockB.inputList[0].connection); - }); - }); - - suite('two stacks', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'B', - }, - }, - }, - { - 'type': 'stack_block', - 'id': 'C', - 'x': 100, - 'y': 100, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'D', - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - }); - teardown(function () { - this.workspace.clear(); - }); - test('getFirstNode', function () { - const node = this.cursor.getFirstNode(); - const location = node; - const blockA = this.workspace.getBlockById('A'); - assert.equal(location, blockA); - }); - test('getLastNode', function () { - const node = this.cursor.getLastNode(); - const location = node; - const blockD = this.workspace.getBlockById('D'); - assert.equal(location, blockD); - }); - }); - }); - suite('Get next node', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'empty_block', - 'message0': '', - }, - { - 'type': 'stack_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'FIELD', - 'text': 'default', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'row_block', - 'message0': '%1 %2', - 'args0': [ - { - 'type': 'field_input', - 'name': 'FIELD', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'INPUT', - }, - ], - 'output': null, - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - this.neverValid = () => false; - this.alwaysValid = () => true; - this.isBlock = (node) => { - return node && node instanceof Blockly.BlockSvg; - }; - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - suite('stack', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'B', - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'C', - }, - }, - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - this.blockA = this.workspace.getBlockById('A'); - this.blockB = this.workspace.getBlockById('B'); - this.blockC = this.workspace.getBlockById('C'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('Never valid - start at top', function () { - const startNode = this.blockA.previousConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(nextNode); - }); - test('Never valid - start in middle', function () { - const startNode = this.blockB; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(nextNode); - }); - test('Never valid - start at end', function () { - const startNode = this.blockC.nextConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(nextNode); - }); - - test('Always valid - start at top', function () { - const startNode = this.blockA.previousConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(nextNode, this.blockA); - }); - test('Always valid - start in middle', function () { - const startNode = this.blockB; - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(nextNode, this.blockB.getField('FIELD')); - }); - test('Always valid - start at end', function () { - const startNode = this.blockC.getField('FIELD'); - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - false, - ); - assert.isNull(nextNode); - }); - - test('Valid if block - start at top', function () { - const startNode = this.blockA; - const nextNode = this.cursor.getNextNode( - startNode, - this.isBlock, - false, - ); - assert.equal(nextNode, this.blockB); - }); - test('Valid if block - start in middle', function () { - const startNode = this.blockB; - const nextNode = this.cursor.getNextNode( - startNode, - this.isBlock, - false, - ); - assert.equal(nextNode, this.blockC); - }); - test('Valid if block - start at end', function () { - const startNode = this.blockC.getField('FIELD'); - const nextNode = this.cursor.getNextNode( - startNode, - this.isBlock, - false, - ); - assert.isNull(nextNode); - }); - test('Never valid - start at end - with loopback', function () { - const startNode = this.blockC.nextConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.neverValid, - true, - ); - assert.isNull(nextNode); - }); - test('Always valid - start at end - with loopback', function () { - const startNode = this.blockC.nextConnection; - const nextNode = this.cursor.getNextNode( - startNode, - this.alwaysValid, - true, - ); - assert.equal(nextNode, this.blockA.previousConnection); - }); - - test('Valid if block - start at end - with loopback', function () { - const startNode = this.blockC; - const nextNode = this.cursor.getNextNode(startNode, this.isBlock, true); - assert.equal(nextNode, this.blockA); - }); - }); - }); - - suite('Get previous node', function () { - setup(function () { - sharedTestSetup.call(this); - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'empty_block', - 'message0': '', - }, - { - 'type': 'stack_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'FIELD', - 'text': 'default', - }, - ], - 'previousStatement': null, - 'nextStatement': null, - }, - { - 'type': 'row_block', - 'message0': '%1 %2', - 'args0': [ - { - 'type': 'field_input', - 'name': 'FIELD', - 'text': 'default', - }, - { - 'type': 'input_value', - 'name': 'INPUT', - }, - ], - 'output': null, - }, - ]); - this.workspace = Blockly.inject('blocklyDiv', {}); - this.cursor = this.workspace.getCursor(); - this.neverValid = () => false; - this.alwaysValid = () => true; - this.isBlock = (node) => { - return node && node instanceof Blockly.BlockSvg; - }; - }); - teardown(function () { - sharedTestTeardown.call(this); - }); - suite('stack', function () { - setup(function () { - const state = { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'stack_block', - 'id': 'A', - 'x': 0, - 'y': 0, - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'B', - 'next': { - 'block': { - 'type': 'stack_block', - 'id': 'C', - }, - }, - }, - }, - }, - ], - }, - }; - Blockly.serialization.workspaces.load(state, this.workspace); - this.blockA = this.workspace.getBlockById('A'); - this.blockB = this.workspace.getBlockById('B'); - this.blockC = this.workspace.getBlockById('C'); - }); - teardown(function () { - this.workspace.clear(); - }); - test('Never valid - start at top', function () { - const startNode = this.blockA.previousConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(previousNode); - }); - test('Never valid - start in middle', function () { - const startNode = this.blockB; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(previousNode); - }); - test('Never valid - start at end', function () { - const startNode = this.blockC.nextConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - false, - ); - assert.isNull(previousNode); - }); - - test('Always valid - start at top', function () { - const startNode = this.blockA; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - false, - ); - assert.isNull(previousNode); - }); - test('Always valid - start in middle', function () { - const startNode = this.blockB; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(previousNode, this.blockA.getField('FIELD')); - }); - test('Always valid - start at end', function () { - const startNode = this.blockC.nextConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - false, - ); - assert.equal(previousNode, this.blockC.getField('FIELD')); - }); - - test('Valid if block - start at top', function () { - const startNode = this.blockA; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - false, - ); - assert.isNull(previousNode); - }); - test('Valid if block - start in middle', function () { - const startNode = this.blockB; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - false, - ); - assert.equal(previousNode, this.blockA); - }); - test('Valid if block - start at end', function () { - const startNode = this.blockC; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - false, - ); - assert.equal(previousNode, this.blockB); - }); - test('Never valid - start at top - with loopback', function () { - const startNode = this.blockA.previousConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.neverValid, - true, - ); - assert.isNull(previousNode); - }); - test('Always valid - start at top - with loopback', function () { - const startNode = this.blockA.previousConnection; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.alwaysValid, - true, - ); - assert.equal(previousNode, this.blockC.nextConnection); - }); - test('Valid if block - start at top - with loopback', function () { - const startNode = this.blockA; - const previousNode = this.cursor.getPreviousNode( - startNode, - this.isBlock, - true, - ); - assert.equal(previousNode, this.blockC); - }); - }); - }); -}); diff --git a/packages/blockly/tests/mocha/field_checkbox_test.js b/packages/blockly/tests/mocha/field_checkbox_test.js index 74357338a..c639f3581 100644 --- a/packages/blockly/tests/mocha/field_checkbox_test.js +++ b/packages/blockly/tests/mocha/field_checkbox_test.js @@ -205,9 +205,6 @@ suite('Checkbox Fields', function () { field.sourceBlock_ = { RTL: false, rendered: true, - workspace: { - keyboardAccessibilityMode: false, - }, queueRender: function () { field.render_(); }, diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index 7cafd00d9..0af0efbab 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -447,6 +447,7 @@ suite('Text Input Fields', function () { this.workspace.getBlockById('right_input_block'); const leftField = this.getFieldFromShadowBlock(leftInputBlock); const rightField = this.getFieldFromShadowBlock(rightInputBlock); + Blockly.getFocusManager().focusNode(leftField); leftField.showEditor(); // This must be called to avoid editor resize logic throwing an error. await Blockly.renderManagement.finishQueuedRenders(); @@ -530,6 +531,7 @@ suite('Text Input Fields', function () { this.workspace.getBlockById('right_input_block'); const leftField = this.getFieldFromShadowBlock(leftInputBlock); const rightField = this.getFieldFromShadowBlock(rightInputBlock); + Blockly.getFocusManager().focusNode(leftField); leftField.showEditor(); // This must be called to avoid editor resize logic throwing an error. await Blockly.renderManagement.finishQueuedRenders(); diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index e75b145d5..44344b3b4 100644 --- a/packages/blockly/tests/mocha/index.html +++ b/packages/blockly/tests/mocha/index.html @@ -170,7 +170,6 @@ import './connection_test.js'; import './contextmenu_items_test.js'; import './contextmenu_test.js'; - import './cursor_test.js'; import './dialog_test.js'; import './dropdowndiv_test.js'; import './event_test.js'; @@ -222,6 +221,7 @@ import './json_test.js'; import './keyboard_movement_test.js'; import './keyboard_navigation_controller_test.js'; + import './keyboard_navigation_test.js'; import './layering_test.js'; import './blocks/lists_test.js'; import './blocks/logic_ternary_test.js'; @@ -339,9 +339,10 @@ - + + + + diff --git a/packages/blockly/tests/mocha/keyboard_navigation_test.js b/packages/blockly/tests/mocha/keyboard_navigation_test.js new file mode 100644 index 000000000..e3491dca5 --- /dev/null +++ b/packages/blockly/tests/mocha/keyboard_navigation_test.js @@ -0,0 +1,403 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from '../../build/src/core/blockly.js'; +import {assert} from '../../node_modules/chai/index.js'; +import {navigationTestBlocks} from './test_helpers/navigation_test_blocks.js'; +import {p5blocks} from './test_helpers/p5_blocks.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; +import {createKeyDownEvent} from './test_helpers/user_input.js'; + +/** + * Dispatches a keydown event with the given keycode on the workspace injection + * div. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace to dispatch on. + * @param {number} keyCode The key code to dispatch. + * @param {!Array=} modifiers Optional modifier key codes. + */ +function pressKey(workspace, keyCode, modifiers) { + const event = createKeyDownEvent(keyCode, modifiers); + workspace.getInjectionDiv().dispatchEvent(event); +} + +/** + * Dispatches a keydown event with the given keycode multiple times. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace to dispatch on. + * @param {number} keyCode The key code to dispatch. + * @param {number} times The number of times to press the key. + * @param {!Array=} modifiers Optional modifier key codes. + */ +function pressKeyN(workspace, keyCode, times, modifiers) { + for (let i = 0; i < times; i++) { + pressKey(workspace, keyCode, modifiers); + } +} + +/** + * Focuses the block with the given ID on the given workspace. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace containing the block. + * @param {string} blockId The ID of the block to focus. + */ +function focusBlock(workspace, blockId) { + const block = workspace.getBlockById(blockId); + if (!block) throw new Error(`No block found with ID: ${blockId}`); + Blockly.getFocusManager().focusNode(block); +} + +/** + * Focuses the named field on a block. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace containing the block. + * @param {string} blockId The ID of the block. + * @param {string} fieldName The name of the field to focus. + */ +function focusBlockField(workspace, blockId, fieldName) { + const block = workspace.getBlockById(blockId); + if (!block) throw new Error(`No block found with ID: ${blockId}`); + const field = block.getField(fieldName); + if (!field) { + throw new Error(`No field found: ${fieldName} (block ${blockId})`); + } + Blockly.getFocusManager().focusNode(field); +} + +/** + * Returns the block ID of the currently focused node, or undefined if the + * focused node is not a block. + * + * @returns {string|undefined} ID of the focused block, if any. + */ +function getFocusedBlockId() { + const node = Blockly.getFocusManager().getFocusedNode(); + if (node instanceof Blockly.BlockSvg) return node.id; + return undefined; +} + +/** + * Returns the DOM element ID of the currently focused node's focusable element. + * + * @returns {string|undefined} ID of the focused node, if any. + */ +function getFocusNodeId() { + return Blockly.getFocusManager().getFocusedNode()?.getFocusableElement()?.id; +} + +/** + * Returns the name of the currently focused field, or undefined if the focused + * node is not a field. + * + * @returns {string|undefined} Name of the focused field, if any. + */ +function getFocusedFieldName() { + return Blockly.getFocusManager().getFocusedNode()?.name; +} + +/** + * Returns the block type of the currently focused node, or undefined if the + * focused node is not a block. + * + * @returns {string|undefined} Type of the focused block, if any. + */ +function getFocusedBlockType() { + const node = Blockly.getFocusManager().getFocusedNode(); + if (node instanceof Blockly.BlockSvg) return node.type; + return undefined; +} + +/** + * Focuses the workspace comment with the given ID. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace containing the comment. + * @param {string} commentId The ID of the workspace comment to focus. + */ +function focusWorkspaceComment(workspace, commentId) { + const comment = workspace.getCommentById(commentId); + if (!comment) { + throw new Error(`No workspace comment found with ID: ${commentId}`); + } + Blockly.getFocusManager().focusNode(comment); +} + +suite('Keyboard navigation on Blocks', function () { + setup(async function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: 'zelos', + }); + Blockly.common.defineBlocks(p5blocks); + Blockly.serialization.workspaces.load(navigationTestBlocks, this.workspace); + for (const block of this.workspace.getAllBlocks()) { + block.initSvg(); + block.render(); + } + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Default workspace', function () { + const blockCount = this.workspace.getAllBlocks(false).length; + assert.equal(blockCount, 16); + }); + + test('Selected block', function () { + Blockly.getFocusManager().focusTree(this.workspace); + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 13); + assert.equal(getFocusedBlockId(), 'controls_repeat_ext_1'); + }); + + test('Down from statement block selects next block across stacks', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + // The first down moves to the next connection on the selected block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusedBlockId(), 'p5_draw_1'); + }); + + test('Up from statement block selects previous block', function () { + focusBlock(this.workspace, 'simple_circle_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'draw_emoji_1'); + }); + + test('Down from parent block selects first child block', function () { + focusBlock(this.workspace, 'p5_setup_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'p5_canvas_1'); + }); + + test('Up from child block selects parent block', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'p5_setup_1'); + }); + + test('Right from block selects first field', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'WIDTH'); + }); + + test('Right from block selects first inline input', function () { + focusBlock(this.workspace, 'simple_circle_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'colour_picker_1'); + }); + + test('Up from inline input selects statement block', function () { + focusBlock(this.workspace, 'math_number_2'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getBlockById('simple_circle_1').nextConnection, + ); + }); + + test('Left from first inline input selects block', function () { + focusBlock(this.workspace, 'math_number_2'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'math_modulo_1'); + }); + + test('Right from first inline input selects second inline input', function () { + focusBlock(this.workspace, 'math_number_2'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'math_number_3'); + }); + + test('Left from second inline input selects first inline input', function () { + focusBlock(this.workspace, 'math_number_3'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'math_number_2'); + }); + + test('Right from last inline input block selects next child field', function () { + focusBlock(this.workspace, 'colour_picker_1'); + // Go right twice; should not wrap to next row. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.RIGHT, 2); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getBlockById('colour_picker_1').getField('TEXT'), + ); + }); + + test('Down from inline input selects next block', function () { + focusBlock(this.workspace, 'colour_picker_1'); + // Go down twice; first one selects the next connection on the colour + // picker's parent block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusedBlockId(), 'controls_repeat_ext_1'); + }); + + test("Down from inline input selects block's child block", function () { + focusBlock(this.workspace, 'logic_boolean_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'text_print_1'); + }); + + test('Right from text block selects shadow block then field', function () { + focusBlock(this.workspace, 'text_print_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'text_1'); + + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'text_1_field_'); + }); +}); + +suite('Keyboard navigation on Fields', function () { + setup(function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: 'zelos', + }); + Blockly.common.defineBlocks(p5blocks); + Blockly.serialization.workspaces.load(navigationTestBlocks, this.workspace); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Up from first field selects previous block', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'p5_setup_1'); + }); + + test('Left from first field selects block', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'p5_canvas_1'); + }); + + test('Right from first field selects second field', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'HEIGHT'); + }); + + test('Left from second field selects first field', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'HEIGHT'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'WIDTH'); + }); + + test('Right from second field selects does not change focus', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'HEIGHT'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getBlockById('p5_canvas_1').getField('HEIGHT'), + ); + }); + + test('Down from field selects next block', function () { + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + // Go down twice; first one selects the next connection on the create + // canvas block. + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusedBlockId(), 'p5_draw_1'); + }); + + test("Down from field selects block's child block", function () { + focusBlockField(this.workspace, 'controls_repeat_1', 'TIMES'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'draw_emoji_1'); + }); +}); + +suite('Workspace comment navigation', function () { + setup(async function () { + sharedTestSetup.call(this); + const toolbox = document.getElementById('toolbox-simple'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox: toolbox, + renderer: 'zelos', + }); + Blockly.common.defineBlocks(p5blocks); + Blockly.serialization.workspaces.load(navigationTestBlocks, this.workspace); + this.workspace.getTopBlocks(false).forEach((b) => b.queueRender()); + Blockly.renderManagement.triggerQueuedRenders(this.workspace); + + const comment1 = Blockly.serialization.workspaceComments.append( + {text: 'Comment one', x: 200, y: 200}, + this.workspace, + ); + const comment2 = Blockly.serialization.workspaceComments.append( + {text: 'Comment two', x: 300, y: 300}, + this.workspace, + ); + this.commentId1 = comment1.id; + this.commentId2 = comment2.id; + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Navigate forward from block to workspace comment', function () { + focusBlock(this.workspace, 'p5_canvas_1'); + pressKeyN(this.workspace, Blockly.utils.KeyCodes.DOWN, 2); + assert.equal(getFocusNodeId(), this.commentId1); + }); + + test('Navigate forward from workspace comment to block', function () { + focusWorkspaceComment(this.workspace, this.commentId2); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockType(), 'p5_draw'); + }); + + test('Navigate backward from block to workspace comment', function () { + focusBlock(this.workspace, 'p5_draw_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusNodeId(), this.commentId2); + }); + + test('Navigate backward from workspace comment to block', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKeyN(this.workspace, Blockly.utils.KeyCodes.UP, 2); + assert.equal(getFocusedBlockType(), 'p5_canvas'); + }); + + test('Navigate forward from workspace comment to workspace comment', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusNodeId(), this.commentId2); + }); + + test('Navigate backward from workspace comment to workspace comment', function () { + focusWorkspaceComment(this.workspace, this.commentId2); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusNodeId(), this.commentId1); + }); + + test('Navigate forward from workspace comment to workspace comment button', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusNodeId(), `${this.commentId1}_collapse_bar_button`); + }); + + test('Navigate backward from workspace comment button to workspace comment', function () { + focusWorkspaceComment(this.workspace, this.commentId1); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusNodeId(), this.commentId1); + }); +}); diff --git a/packages/blockly/tests/mocha/navigation_test.js b/packages/blockly/tests/mocha/navigation_test.js index 2aad16986..3b9660a4c 100644 --- a/packages/blockly/tests/mocha/navigation_test.js +++ b/packages/blockly/tests/mocha/navigation_test.js @@ -21,21 +21,21 @@ suite('Navigation', function () { 'args0': [ { 'type': 'field_input', - 'name': 'NAME', + 'name': 'NAME1', 'text': 'default', }, { 'type': 'field_input', - 'name': 'NAME', + 'name': 'NAME2', 'text': 'default', }, { 'type': 'input_value', - 'name': 'NAME', + 'name': 'NAME3', }, { 'type': 'input_statement', - 'name': 'NAME', + 'name': 'NAME4', }, ], 'previousStatement': null, @@ -444,7 +444,7 @@ suite('Navigation', function () { const nextConnection = this.blocks.statementInput1.nextConnection; const prevConnection = this.blocks.statementInput2.previousConnection; const nextNode = this.navigator.getNextSibling(nextConnection); - assert.equal(nextNode, prevConnection); + assert.equal(nextNode, prevConnection.getSourceBlock()); }); test('fromInputToInput', function () { const input = this.blocks.doubleValueInput.inputList[0]; @@ -471,8 +471,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); }); @@ -531,13 +529,6 @@ suite('Navigation', function () { ); assert.equal(nextNode, field); }); - test('fromBlockToFieldSkippingInput', function () { - const field = this.blocks.buttonBlock.getField('BUTTON3'); - const nextNode = this.navigator.getNextSibling( - this.blocks.buttonInput2, - ); - assert.equal(nextNode, field); - }); test('skipsChildrenOfCollapsedBlocks', function () { this.blocks.buttonBlock.setCollapsed(true); const nextNode = this.navigator.getNextSibling(this.blocks.buttonBlock); @@ -545,6 +536,7 @@ suite('Navigation', function () { }); test('fromFieldSkipsHiddenInputs', function () { this.blocks.buttonBlock.inputList[2].setVisible(false); + this.blocks.buttonBlock.inputList[3].setVisible(false); const fieldStart = this.blocks.buttonBlock.getField('BUTTON2'); const fieldEnd = this.blocks.buttonBlock.getField('BUTTON3'); const nextNode = this.navigator.getNextSibling(fieldStart); @@ -553,16 +545,19 @@ suite('Navigation', function () { }); suite('Previous', function () { - test('fromPreviousToNext', function () { + test('fromPreviousToPriorBlock', function () { const prevConnection = this.blocks.statementInput2.previousConnection; const prevNode = this.navigator.getPreviousSibling(prevConnection); const nextConnection = this.blocks.statementInput1.nextConnection; - assert.equal(prevNode, nextConnection); + assert.equal(prevNode, nextConnection.getSourceBlock()); }); test('fromPreviousToInput', function () { const prevConnection = this.blocks.statementInput3.previousConnection; const prevNode = this.navigator.getPreviousSibling(prevConnection); - assert.isNull(prevNode); + assert.equal( + prevNode, + this.blocks.statementInput2.inputList[0].connection, + ); }); test('fromBlockToPrevious', function () { const prevNode = this.navigator.getPreviousSibling( @@ -575,7 +570,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 () { @@ -605,10 +599,10 @@ suite('Navigation', function () { const prevNode = this.navigator.getPreviousSibling(input.connection); assert.equal(prevNode, inputConnection); }); - test('fromOutputToNull', function () { + test('fromOutputToField', function () { const output = this.blocks.fieldWithOutput.outputConnection; const prevNode = this.navigator.getPreviousSibling(output); - assert.isNull(prevNode); + assert.equal(this.blocks.statementInput1.getField('NAME2'), prevNode); }); test('fromFieldToNull', function () { const field = this.blocks.statementInput1.inputList[0].fieldRow[0]; @@ -622,8 +616,6 @@ suite('Navigation', function () { ); 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); }); @@ -692,6 +684,7 @@ suite('Navigation', function () { }); test('fromFieldSkipsHiddenInputs', function () { this.blocks.buttonBlock.inputList[2].setVisible(false); + this.blocks.buttonBlock.inputList[3].setVisible(false); const fieldStart = this.blocks.buttonBlock.getField('BUTTON3'); const fieldEnd = this.blocks.buttonBlock.getField('BUTTON2'); const nextNode = this.navigator.getPreviousSibling(fieldStart); @@ -709,24 +702,11 @@ suite('Navigation', function () { workspaceTeardown.call(this, this.emptyWorkspace); }); - test('fromInputToOutput', function () { - const input = this.blocks.statementInput1.inputList[0]; - const inNode = this.navigator.getFirstChild(input.connection); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; - assert.equal(inNode, outputConnection); - }); test('fromInputToNull', function () { const input = this.blocks.statementInput2.inputList[0]; const inNode = this.navigator.getFirstChild(input.connection); assert.isNull(inNode); }); - test('fromInputToPrevious', function () { - const input = this.blocks.statementInput2.inputList[1]; - const previousConnection = - this.blocks.statementInput3.previousConnection; - const inNode = this.navigator.getFirstChild(input.connection); - assert.equal(inNode, previousConnection); - }); test('fromBlockToInput', function () { const connection = this.blocks.valueInput.inputList[0].connection; const inNode = this.navigator.getFirstChild(this.blocks.valueInput); diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 6edef8c3e..44e1e42bf 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -552,6 +552,56 @@ suite('Keyboard Shortcut Items', function () { }); }); + suite('Focus Toolbox (T)', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'basic_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'TEXT', + 'text': 'default', + }, + ], + }, + ]); + }); + + test('Does not change focus when toolbox item is already focused', function () { + const item = this.workspace.getToolbox().getToolboxItems()[1]; + Blockly.getFocusManager().focusNode(item); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.T); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), item); + }); + + test('Focuses toolbox when workspace is focused', function () { + Blockly.getFocusManager().focusTree(this.workspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.T); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.workspace.getToolbox(), + ); + }); + + test('Focuses mutator flyout when mutator workspace is focused', async function () { + const block = this.workspace.newBlock('controls_if'); + const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + const mutatorWorkspace = icon.getWorkspace(); + Blockly.getFocusManager().focusTree(mutatorWorkspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.T); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + mutatorWorkspace.getFlyout().getWorkspace(), + ); + }); + }); + suite('Disconnect Block (X)', function () { setup(function () { this.blockA = this.workspace.newBlock('stack_block'); diff --git a/packages/blockly/tests/mocha/shortcut_registry_test.js b/packages/blockly/tests/mocha/shortcut_registry_test.js index a06f01b9c..a7a7c4d81 100644 --- a/packages/blockly/tests/mocha/shortcut_registry_test.js +++ b/packages/blockly/tests/mocha/shortcut_registry_test.js @@ -21,6 +21,9 @@ suite('Keyboard Shortcut Registry Test', function () { }); teardown(function () { sharedTestTeardown.call(this); + this.registry.reset(); + Blockly.ShortcutItems.registerDefaultShortcuts(); + Blockly.ShortcutItems.registerKeyboardNavigationShortcuts(); }); suite('Registering', function () { @@ -528,6 +531,4 @@ suite('Keyboard Shortcut Registry Test', function () { assert.throws(shouldThrow, Error, 's is not a valid modifier key.'); }); }); - - teardown(function () {}); }); diff --git a/packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js b/packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js new file mode 100644 index 000000000..a6e062b20 --- /dev/null +++ b/packages/blockly/tests/mocha/test_helpers/navigation_test_blocks.js @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Workspace state for keyboard navigation tests. Contains: + * - p5_setup with a p5_canvas child + * - p5_draw with a nested stack: controls_if → controls_if (with + * logic_boolean input and text_print child) → controls_repeat (with + * draw_emoji and simple_circle children) → controls_repeat_ext (with a + * math_modulo expression in its TIMES input) + * + * Block IDs are stable so tests can reference them by ID. + */ +export const navigationTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup_1', + 'x': 0, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas_1', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw_1', + 'x': 0, + 'y': 332, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if_1', + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if_2', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_boolean', + 'id': 'logic_boolean_1', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + 'DO0': { + 'block': { + 'type': 'text_print', + 'id': 'text_print_1', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'text_1', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat', + 'id': 'controls_repeat_1', + 'fields': { + 'TIMES': 10, + }, + 'inputs': { + 'DO': { + 'block': { + 'type': 'draw_emoji', + 'id': 'draw_emoji_1', + 'fields': { + 'emoji': '❤️', + }, + 'next': { + 'block': { + 'type': 'simple_circle', + 'id': 'simple_circle_1', + 'inputs': { + 'COLOR': { + 'shadow': { + 'type': 'text', + 'id': 'colour_picker_1', + 'fields': { + 'TEXT': '#ff0000', + }, + }, + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext_1', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_1', + 'fields': { + 'NUM': 10, + }, + }, + 'block': { + 'type': 'math_modulo', + 'id': 'math_modulo_1', + 'inputs': { + 'DIVIDEND': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_2', + 'fields': { + 'NUM': 64, + }, + }, + }, + 'DIVISOR': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_3', + 'fields': { + 'NUM': 10, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, +}; diff --git a/packages/blockly/tests/mocha/toolbox_test.js b/packages/blockly/tests/mocha/toolbox_test.js index 480fdfdc6..4b1af1427 100644 --- a/packages/blockly/tests/mocha/toolbox_test.js +++ b/packages/blockly/tests/mocha/toolbox_test.js @@ -13,7 +13,6 @@ import { import { getBasicToolbox, getCategoryJSON, - getChildItem, getCollapsibleItem, getDeeplyNestedJSON, getInjectedToolbox, @@ -26,21 +25,16 @@ import { suite('Toolbox', function () { setup(function () { sharedTestSetup.call(this); + this.toolbox = getInjectedToolbox(); defineStackBlock(); }); teardown(function () { + this.toolbox.dispose(); sharedTestTeardown.call(this); }); suite('init', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - test('Init called -> HtmlDiv is created', function () { assert.isDefined(this.toolbox.HtmlDiv); }); @@ -87,12 +81,6 @@ suite('Toolbox', function () { }); suite('render', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); test('Render called with valid toolboxDef -> Contents are created', function () { const positionStub = sinon.stub(this.toolbox, 'position'); this.toolbox.render({ @@ -184,13 +172,6 @@ suite('Toolbox', function () { }); suite('focus management', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - test('Losing focus hides autoclosing flyout', function () { // Focus the toolbox and select a category to open the flyout. const target = this.toolbox.HtmlDiv.querySelector( @@ -235,13 +216,6 @@ suite('Toolbox', function () { }); suite('onClick_', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - test('Toolbox clicked -> Should close flyout', function () { const hideChaffStub = sinon.stub( Blockly.WorkspaceSvg.prototype, @@ -267,220 +241,251 @@ suite('Toolbox', function () { }); }); - suite('onKeyDown_', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); + suite('on key down', function () { + test('Down arrow should select next item', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex + 1, newIndex); }); - function createKeyDownMock(key) { - return { - 'key': key, - 'preventDefault': function () {}, - }; - } - - function testCorrectFunctionCalled(toolbox, key, funcName) { - const event = createKeyDownMock(key); - const preventDefaultEvent = sinon.stub(event, 'preventDefault'); - const selectMethodStub = sinon.stub(toolbox, funcName); - selectMethodStub.returns(true); - toolbox.onKeyDown_(event); - sinon.assert.called(selectMethodStub); - sinon.assert.called(preventDefaultEvent); - } - - test('Down button is pushed -> Should call selectNext', function () { - testCorrectFunctionCalled(this.toolbox, 'ArrowDown', 'selectNext', true); + test('Down arrow should skip separators', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[1]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Item at index 2 is a separator, new index should have incremented by + // 2 instead of 1 to bypass it. + assert.equal(oldIndex + 2, newIndex); }); - test('Up button is pushed -> Should call selectPrevious', function () { - testCorrectFunctionCalled( - this.toolbox, - 'ArrowUp', - 'selectPrevious', - true, + + test('Down arrow should skip children of collapsed item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + assert.isFalse(collapsibleItem.isExpanded()); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is not expanded, so down should skip its child item + // and advance to the next regular item. + assert.equal(oldIndex + 2, newIndex); + }); + + test('Down arrow should go to first child of expanded item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + collapsibleItem.setExpanded(true); + assert.isTrue(collapsibleItem.isExpanded()); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is expanded, so down should focus its child item. + assert.equal(oldIndex + 1, newIndex); + }); + + test('Down arrow on last item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[6]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.DOWN, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex, newIndex); + }); + + test('Up arrow should select previous item', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[1]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex - 1, newIndex); + }); + + test('Up arrow should skip separators', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[3]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Item at index 2 is a separator, new index should have decremented by + // 2 instead of 1 to bypass it. + assert.equal(oldIndex - 2, newIndex); + }); + + test('Up arrow should skip children of collapsed item', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[6]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is not expanded, so up should skip its child item + // and advance to it directly. + assert.equal(oldIndex - 2, newIndex); + }); + + test('Up arrow should go to parent from child item', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[5]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + // Collapsible item is expanded, so up from its child should go to it. + assert.equal(oldIndex - 1, newIndex); + }); + + test('Up arrow on first item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.UP, + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + const newIndex = items.indexOf(this.toolbox.getSelectedItem()); + assert.equal(oldIndex, newIndex); + }); + + test('Left arrow should collapse expanded item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + collapsibleItem.setExpanded(true); + assert.isTrue(collapsibleItem.isExpanded()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', + }); + this.toolbox.contentsDiv_.dispatchEvent(event); + assert.isFalse(collapsibleItem.isExpanded()); + }); + + test('Left arrow from normal item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), items[0]); + }); + + test('Left arrow from collapsed item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[4]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), items[4]); + }); + + test('Left arrow from child item should be a no-op', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[5]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.LEFT, + key: 'ArrowLeft', + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), items[5]); + }); + + test('Right arrow should expand collapsed item', function () { + const items = this.toolbox.getToolboxItems(); + const collapsibleItem = items[4]; + Blockly.getFocusManager().focusNode(collapsibleItem); + assert.isFalse(collapsibleItem.isExpanded()); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', + }); + this.toolbox.contentsDiv_.dispatchEvent(event); + assert.isTrue(collapsibleItem.isExpanded()); + }); + + test('Right arrow from normal item should focus flyout', function () { + const items = this.toolbox.getToolboxItems(); + Blockly.getFocusManager().focusNode(items[0]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.toolbox.getFlyout().getWorkspace(), ); }); - test('Left button is pushed -> Should call selectParent', function () { - testCorrectFunctionCalled( - this.toolbox, - 'ArrowLeft', - 'selectParent', - true, + + test('Right arrow from expanded item should focus flyout', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[4]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.toolbox.getFlyout().getWorkspace(), ); }); - test('Right button is pushed -> Should call selectChild', function () { - testCorrectFunctionCalled( - this.toolbox, - 'ArrowRight', - 'selectChild', - true, + + test('Right arrow from child item should focus flyout', function () { + const items = this.toolbox.getToolboxItems(); + items[4].setExpanded(true); + Blockly.getFocusManager().focusNode(items[5]); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.RIGHT, + key: 'ArrowRight', + }); + this.toolbox.getWorkspace().getInjectionDiv().dispatchEvent(event); + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + this.toolbox.getFlyout().getWorkspace(), ); }); - test('Enter button is pushed -> Should toggle expanded', function () { - this.toolbox.selectedItem_ = getCollapsibleItem(this.toolbox); - const toggleExpandedStub = sinon.stub( - this.toolbox.selectedItem_, - 'toggleExpanded', - ); - const event = createKeyDownMock('Enter'); - const preventDefaultEvent = sinon.stub(event, 'preventDefault'); - this.toolbox.onKeyDown_(event); - sinon.assert.called(toggleExpandedStub); - sinon.assert.called(preventDefaultEvent); - }); - test('Enter button is pushed when no item is selected -> Should not call prevent default', function () { - this.toolbox.selectedItem_ = null; - const event = createKeyDownMock('Enter'); - const preventDefaultEvent = sinon.stub(event, 'preventDefault'); - this.toolbox.onKeyDown_(event); - sinon.assert.notCalled(preventDefaultEvent); - }); - }); - - suite('Select Methods', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - - suite('selectChild', function () { - test('No item is selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectChild(); - assert.isFalse(handled); - }); - test('Selected item is not collapsible -> Should not handle event', function () { - this.toolbox.selectedItem_ = getNonCollapsibleItem(this.toolbox); - const handled = this.toolbox.selectChild(); - assert.isFalse(handled); - }); - test('Selected item is collapsible -> Should expand', function () { - const collapsibleItem = getCollapsibleItem(this.toolbox); - this.toolbox.selectedItem_ = collapsibleItem; - const handled = this.toolbox.selectChild(); - assert.isTrue(handled); - assert.isTrue(collapsibleItem.isExpanded()); - assert.equal(this.toolbox.selectedItem_, collapsibleItem); - }); - - test('Selected item is expanded -> Should select child', function () { - const collapsibleItem = getCollapsibleItem(this.toolbox); - collapsibleItem.expanded_ = true; - const selectNextStub = sinon.stub(this.toolbox, 'selectNext'); - this.toolbox.selectedItem_ = collapsibleItem; - const handled = this.toolbox.selectChild(); - assert.isTrue(handled); - sinon.assert.called(selectNextStub); - }); - }); - - suite('selectParent', function () { - test('No item selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectParent(); - assert.isFalse(handled); - }); - test('Selected item is expanded -> Should collapse', function () { - const collapsibleItem = getCollapsibleItem(this.toolbox); - collapsibleItem.expanded_ = true; - this.toolbox.selectedItem_ = collapsibleItem; - const handled = this.toolbox.selectParent(); - assert.isTrue(handled); - assert.isFalse(collapsibleItem.isExpanded()); - assert.equal(this.toolbox.selectedItem_, collapsibleItem); - }); - test('Selected item is not expanded -> Should get parent', function () { - const childItem = getChildItem(this.toolbox); - this.toolbox.selectedItem_ = childItem; - const handled = this.toolbox.selectParent(); - assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, childItem.getParent()); - }); - }); - - suite('selectNext', function () { - test('No item is selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectNext(); - assert.isFalse(handled); - }); - test('Next item is selectable -> Should select next item', function () { - const items = [...this.toolbox.contents.values()]; - const item = items[0]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectNext(); - assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, items[1]); - }); - test('Selected item is last item -> Should not handle event', function () { - const items = [...this.toolbox.contents.values()]; - const item = items.at(-1); - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectNext(); - assert.isFalse(handled); - assert.equal(this.toolbox.selectedItem_, item); - }); - test('Selected item is collapsed -> Should skip over its children', function () { - const item = getCollapsibleItem(this.toolbox); - const childItem = item.flyoutItems_[0]; - item.expanded_ = false; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectNext(); - assert.isTrue(handled); - assert.notEqual(this.toolbox.selectedItem_, childItem); - }); - }); - - suite('selectPrevious', function () { - test('No item is selected -> Should not handle event', function () { - this.toolbox.selectedItem_ = null; - const handled = this.toolbox.selectPrevious(); - assert.isFalse(handled); - }); - test('Selected item is first item -> Should not handle event', function () { - const item = [...this.toolbox.contents.values()][0]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectPrevious(); - assert.isFalse(handled); - assert.equal(this.toolbox.selectedItem_, item); - }); - test('Previous item is selectable -> Should select previous item', function () { - const items = [...this.toolbox.contents.values()]; - const item = items[1]; - const prevItem = items[0]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectPrevious(); - assert.isTrue(handled); - assert.equal(this.toolbox.selectedItem_, prevItem); - }); - test('Previous item is collapsed -> Should skip over children of the previous item', function () { - const childItem = getChildItem(this.toolbox); - const parentItem = childItem.getParent(); - const items = [...this.toolbox.contents.values()]; - const parentIdx = items.indexOf(parentItem); - // Gets the item after the parent. - const item = items[parentIdx + 1]; - this.toolbox.selectedItem_ = item; - const handled = this.toolbox.selectPrevious(); - assert.isTrue(handled); - assert.notEqual(this.toolbox.selectedItem_, childItem); - }); - }); }); suite('setSelectedItem', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - function setupSetSelected(toolbox, oldItem, newItem) { toolbox.selectedItem_ = oldItem; const newItemStub = sinon.stub(newItem, 'setSelected'); @@ -526,13 +531,6 @@ suite('Toolbox', function () { }); suite('updateFlyout_', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); - function testHideFlyout(toolbox, oldItem, newItem) { const updateFlyoutStub = sinon.stub(toolbox.getFlyout(), 'hide'); toolbox.updateFlyout_(oldItem, newItem); @@ -778,12 +776,6 @@ suite('Toolbox', function () { }); }); suite('Nested Categories', function () { - setup(function () { - this.toolbox = getInjectedToolbox(); - }); - teardown(function () { - this.toolbox.dispose(); - }); test('Child categories visible if all ancestors expanded', function () { this.toolbox.render(getDeeplyNestedJSON()); const items = [...this.toolbox.contents.values()]; From 5b79a29b7cf66d2f0a0b9c0526101c39157e3e86 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 1 Apr 2026 12:34:07 -0700 Subject: [PATCH 024/200] feat: Update CSS for keyboard navigation (#9674) --- packages/blockly/core/css.ts | 122 ++++++++++++++++++ .../core/renderers/common/constants.ts | 3 + 2 files changed, 125 insertions(+) diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index fdfa9c041..c664d25f8 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -536,4 +536,126 @@ input[type=number] { height: 1px; overflow: hidden; } + +.injectionDiv { + --blockly-active-node-color: #fff200; + --blockly-active-tree-color: #60a5fa; + --blockly-selection-width: 3px; +} + +/* Active focus cases: */ +/* Blocks with active focus. */ +.blocklyKeyboardNavigation + .blocklyActiveFocus:is(.blocklyPath, .blocklyHighlightedConnectionPath), +/* Fields with active focus, */ +.blocklyKeyboardNavigation + .blocklyActiveFocus.blocklyField + > .blocklyFieldRect, +/* Icons with active focus. */ +.blocklyKeyboardNavigation + .blocklyActiveFocus.blocklyIconGroup + > .blocklyIconShape:first-child { + stroke: var(--blockly-active-node-color); + stroke-width: var(--blockly-selection-width); +} + +/* Passive focus cases: */ +/* Blocks with passive focus except when widget/dropdown div in use. */ +.blocklyKeyboardNavigation:not( + :has( + .blocklyDropDownDiv:focus-within, + .blocklyWidgetDiv:focus-within + ) + ) + .blocklyPassiveFocus:is( + .blocklyPath:not(.blocklyFlyout .blocklyPath), + .blocklyHighlightedConnectionPath + ), +/* Fields with passive focus except when widget/dropdown div in use. */ +.blocklyKeyboardNavigation:not( + :has( + .blocklyDropDownDiv:focus-within, + .blocklyWidgetDiv:focus-within + ) + ) + .blocklyPassiveFocus.blocklyField + > .blocklyFieldRect, +/* Icons with passive focus except when widget/dropdown div in use. */ +.blocklyKeyboardNavigation:not( + :has( + .blocklyDropDownDiv:focus-within, + .blocklyWidgetDiv:focus-within + ) + ) + .blocklyPassiveFocus.blocklyIconGroup + > .blocklyIconShape:first-child { + stroke: var(--blockly-active-node-color); + stroke-dasharray: 5px 3px; + stroke-width: var(--blockly-selection-width); +} + +/* Workaround for unexpectedly hidden connection path due to core style. */ +.blocklyKeyboardNavigation + .blocklyPassiveFocus.blocklyHighlightedConnectionPath { + display: unset !important; +} + +/* Different ways for toolbox/flyout to be the active tree: */ +/* Active focus in the flyout. */ +.blocklyKeyboardNavigation .blocklyFlyout:has(.blocklyActiveFocus), +/* Active focus in the toolbox. */ +.blocklyKeyboardNavigation .blocklyToolbox:has(.blocklyActiveFocus), +/* Active focus on the toolbox/flyout. */ +.blocklyKeyboardNavigation + .blocklyActiveFocus:is(.blocklyFlyout, .blocklyToolbox) { + outline-offset: calc(var(--blockly-selection-width) * -1); + outline: var(--blockly-selection-width) solid + var(--blockly-active-tree-color); +} + +/* Suppress default outline. */ +.blocklyKeyboardNavigation + .blocklyToolboxCategoryContainer:focus-visible { + outline: none; +} + + /* Different ways for the workspace to be the active tree: */ +/* Active focus within workspace. */ +.blocklyKeyboardNavigation + .blocklyWorkspace:has(.blocklyActiveFocus) + .blocklyWorkspaceFocusRing, +/* Active focus within drag layer. */ +.blocklyKeyboardNavigation + .blocklySvg:has(~ .blocklyBlockDragSurface .blocklyActiveFocus) + .blocklyWorkspaceFocusRing, +/* Active focus on workspace. */ +.blocklyKeyboardNavigation + .blocklyWorkspace.blocklyActiveFocus + .blocklyWorkspaceFocusRing, +/* Focus in widget/dropdown div considered to be in workspace. */ +.blocklyKeyboardNavigation:has( + .blocklyWidgetDiv:focus-within, + .blocklyDropDownDiv:focus-within +) + .blocklyWorkspace + .blocklyWorkspaceFocusRing { + stroke: var(--blockly-active-tree-color); + stroke-width: calc(var(--blockly-selection-width) * 2); +} + +/* The workspace itself is the active node. */ +.blocklyKeyboardNavigation + .blocklyWorkspace.blocklyActiveFocus + .blocklyWorkspaceSelectionRing { + stroke: var(--blockly-active-node-color); + stroke-width: var(--blockly-selection-width); +} + +/* The workspace itself is the active node. */ +.blocklyKeyboardNavigation + .blocklyBubble.blocklyActiveFocus + .blocklyDraggable { + stroke: var(--blockly-active-node-color); + stroke-width: var(--blockly-selection-width); +} `; diff --git a/packages/blockly/core/renderers/common/constants.ts b/packages/blockly/core/renderers/common/constants.ts index 764cef029..8efe0ae33 100644 --- a/packages/blockly/core/renderers/common/constants.ts +++ b/packages/blockly/core/renderers/common/constants.ts @@ -1153,6 +1153,9 @@ export class ConstantProvider { protected getCSS_(selector: string): string[] { // prettier-ignore return [ + `${selector}.injectionDiv {`, + `--blockly-active-node-color: #fc3;`, + `}`, // Text. `${selector} .blocklyText, `, `${selector} .blocklyFlyoutLabelText {`, From f454e1b3174c72e34a73664f92928e37c6b2066a Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 1 Apr 2026 12:49:20 -0700 Subject: [PATCH 025/200] chore: Bump closure to the latest version --- package-lock.json | 68 ++++++++++++++++++++++++++++++----- packages/blockly/package.json | 2 +- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58d982a5c..0a4f05b47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1093,12 +1093,14 @@ } }, "node_modules/google-closure-compiler": { - "version": "20260315.0.0", + "version": "20260330.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20260330.0.0.tgz", + "integrity": "sha512-USY3fekBavIfAkzHEooo6FcuTT/+z6FbfMRK3l3nsgsKB2oJ4baPPz2XYGSivHNstB8l10CcPvWh5FwlWZpzvQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "chalk": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 <5.6.1 || ^5.6.2 >5.6.1", - "google-closure-compiler-java": "^20260315.0.0", + "google-closure-compiler-java": "^20260330.0.0", "minimist": "^1.0.0", "vinyl": "^3.0.1", "vinyl-sourcemaps-apply": "^0.2.0" @@ -1110,19 +1112,52 @@ "node": ">=18" }, "optionalDependencies": { - "google-closure-compiler-linux": "^20260315.0.0", - "google-closure-compiler-linux-arm64": "^20260315.0.0", - "google-closure-compiler-macos": "^20260315.0.0", - "google-closure-compiler-windows": "^20260315.0.0" + "google-closure-compiler-linux": "^20260330.0.0", + "google-closure-compiler-linux-arm64": "^20260330.0.0", + "google-closure-compiler-macos": "^20260330.0.0", + "google-closure-compiler-windows": "^20260330.0.0" } }, "node_modules/google-closure-compiler-java": { - "version": "20260315.0.0", + "version": "20260330.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20260330.0.0.tgz", + "integrity": "sha512-wc+HpQlNvS5mquzVjpOjhVlgYgvxnOyqPDCJJN2k7+5dVE7mbzZya7mIEfsPaRZ39KbPVvNrDpMEAahKiuNtjA==", "dev": true, "license": "Apache-2.0" }, + "node_modules/google-closure-compiler-linux": { + "version": "20260330.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20260330.0.0.tgz", + "integrity": "sha512-BWEknhsCj/zero4Zk0/FtbAM1eO3QQA34xGQa4HeREV36trs/zmVCSVRqYCYcEAAEBiMjj97wMPdhFSkm72k8g==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/google-closure-compiler-linux-arm64": { + "version": "20260330.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20260330.0.0.tgz", + "integrity": "sha512-7C9khLgtZEUfZ/xCkjLVTC5vRJt5CmfWwiAk/u3+pPzsDubngHwKE1e0UTPqNwJEXo8VVXM4OdvrY7hzwR4MZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/google-closure-compiler-macos": { - "version": "20260315.0.0", + "version": "20260330.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20260330.0.0.tgz", + "integrity": "sha512-3mGI7eYwIZYAZFdMwpS4wXB5+q/kKbl936FtvRIIgRtFhSX7m6kwsyXqGP62ZvuOCcPcW14dC6k7g6l7eS2Fgw==", "cpu": [ "arm64" ], @@ -1133,6 +1168,21 @@ "darwin" ] }, + "node_modules/google-closure-compiler-windows": { + "version": "20260330.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20260330.0.0.tgz", + "integrity": "sha512-dqMKvjzdAlgERhHKHG12dKrOcL2ASNeKP1c3doCQn3vwZizz1xV2Ns6m711tb6pfNXFstfR6VJAoMuv/BjmdYQ==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "dev": true, @@ -1925,7 +1975,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260315.0.0", + "google-closure-compiler": "^20260330.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", diff --git a/packages/blockly/package.json b/packages/blockly/package.json index 89c46f3ba..7f6d53970 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -123,7 +123,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260315.0.0", + "google-closure-compiler": "^20260330.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", From 43be0edf5d2836ded59d083138674a4fd8b3aa7d Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 2 Apr 2026 11:01:29 -0700 Subject: [PATCH 026/200] fix!: Fix types on `BlockSvg` connections (#9669) --- packages/blockly/blocks/text.ts | 6 ++++-- packages/blockly/core/block_svg.ts | 9 +++------ .../blockly/core/dragging/block_drag_strategy.ts | 16 +++++++++++----- packages/blockly/core/renderers/common/drawer.ts | 2 +- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/blockly/blocks/text.ts b/packages/blockly/blocks/text.ts index a7ad5374a..8ab961ee5 100644 --- a/packages/blockly/blocks/text.ts +++ b/packages/blockly/blocks/text.ts @@ -757,13 +757,15 @@ const JOIN_MUTATOR_MIXIN = { 'text_create_join_container', ) as BlockSvg; containerBlock.initSvg(); - let connection = containerBlock.getInput('STACK')!.connection!; + let connection = containerBlock.getInput('STACK')?.connection; for (let i = 0; i < this.itemCount_; i++) { const itemBlock = workspace.newBlock( 'text_create_join_item', ) as JoinItemBlock; itemBlock.initSvg(); - connection.connect(itemBlock.previousConnection); + if (itemBlock.previousConnection) { + connection?.connect(itemBlock.previousConnection); + } connection = itemBlock.nextConnection; } return containerBlock; diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 666436c1e..051d7ace5 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -160,12 +160,9 @@ export class BlockSvg private visuallyDisabled = false; override workspace: WorkspaceSvg; - // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. - override outputConnection!: RenderedConnection; - // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. - override nextConnection!: RenderedConnection; - // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. - override previousConnection!: RenderedConnection; + override outputConnection: RenderedConnection | null = null; + override nextConnection: RenderedConnection | null = null; + override previousConnection: RenderedConnection | null = null; private translation = ''; diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index f4578a941..c521e8b11 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -366,7 +366,7 @@ export class BlockDragStrategy implements IDragStrategy { }; } - this.startChildConn = nextTargetConn; + this.startChildConn = nextTargetConn ?? null; } } } @@ -627,7 +627,7 @@ export class BlockDragStrategy implements IDragStrategy { draggingBlock.outputConnection, draggingBlock.previousConnection, draggingBlock.nextConnection, - ].filter(Boolean); // Removes falsy (null) values. + ].filter((c) => !!c); // Removes falsy (null) values. const inputConnections: RenderedConnection[] = []; for (const conn of available) { @@ -727,14 +727,20 @@ export class BlockDragStrategy implements IDragStrategy { this.connectionPreviewer?.hidePreview(); this.connectionCandidate = null; - this.startChildConn?.connect(this.block.nextConnection); + if (this.block.nextConnection) { + this.startChildConn?.connect(this.block.nextConnection); + } if (this.startParentConn) { switch (this.startParentConn.type) { case ConnectionType.INPUT_VALUE: - this.startParentConn.connect(this.block.outputConnection); + if (this.block.outputConnection) { + this.startParentConn.connect(this.block.outputConnection); + } break; case ConnectionType.NEXT_STATEMENT: - this.startParentConn.connect(this.block.previousConnection); + if (this.block.previousConnection) { + this.startParentConn.connect(this.block.previousConnection); + } } } else { this.block.moveTo(this.startLoc!, ['drag']); diff --git a/packages/blockly/core/renderers/common/drawer.ts b/packages/blockly/core/renderers/common/drawer.ts index c474bc8c3..f70462fa2 100644 --- a/packages/blockly/core/renderers/common/drawer.ts +++ b/packages/blockly/core/renderers/common/drawer.ts @@ -422,7 +422,7 @@ export class Drawer { const x = this.info_.startX + this.info_.outputConnection.connectionOffsetX; const connX = this.info_.RTL ? -x : x; - this.block_.outputConnection.setOffsetInBlock( + this.block_.outputConnection?.setOffsetInBlock( connX, this.info_.outputConnection.connectionOffsetY, ); From dc4d751b935a527594e6e8e67901a43d273b89ae Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 2 Apr 2026 12:08:15 -0700 Subject: [PATCH 027/200] fix: Display focus rings on flyout buttons and labels (#9679) --- packages/blockly/core/css.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index 0384f719f..aeb0e356a 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -648,4 +648,15 @@ input[type=number] { stroke: var(--blockly-active-node-color); stroke-width: var(--blockly-selection-width); } +/* Flyout buttons and labels */ +.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus, +.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutButton.blocklyActiveFocus { + outline: none; +} +.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus > .blocklyFlyoutLabelText, +.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutButton.blocklyActiveFocus > .blocklyFlyoutButtonBackground { + outline-offset: 2px; + outline: var(--blockly-selection-width) solid var(--blockly-active-node-color); + border-radius: 2px; +} `; From 3389f87cee8f086314a5c1f76a0e48a823fbd39b Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 2 Apr 2026 12:26:52 -0700 Subject: [PATCH 028/200] feat: Add keyboard shortcuts to navigate between stacks (#9678) * feat: Add keyboard shortcuts to navigate between stacks * test: Add tests for stack jumping shortcuts * chore: Clarify logic * test: Add additional tests for no-op stack navigation --- .../core/keyboard_nav/navigators/navigator.ts | 3 +- packages/blockly/core/shortcut_items.ts | 73 ++++++++ .../tests/mocha/shortcut_items_test.js | 156 +++++++++++++++++- 3 files changed, 230 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index 36b72ab7d..1a66a965b 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -456,6 +456,7 @@ export class Navigator { /** * Returns the next/previous stack relative to the given element's stack. * + * @internal * @param current The element whose stack will be navigated relative to. * @param delta The difference in index to navigate; positive values navigate * to the nth next stack, while negative values navigate to the nth @@ -464,7 +465,7 @@ export class Navigator { * current element's stack, or the last element in the stack offset by * `delta` relative to the current element's stack when navigating backwards. */ - protected navigateStacks(current: IFocusableNode, delta: number) { + navigateStacks(current: IFocusableNode, delta: number) { const stacks = this.getTopLevelItems(current); const root = this.getSourceBlockFromNode(current)?.getRootBlock() ?? current; diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index c904d04ee..b23b6d67b 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -17,6 +17,7 @@ import {isCopyable as isICopyable} from './interfaces/i_copyable.js'; import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; import {type IDraggable, isDraggable} from './interfaces/i_draggable.js'; import {type IFocusableNode} from './interfaces/i_focusable_node.js'; +import {isSelectable} from './interfaces/i_selectable.js'; import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; @@ -53,6 +54,8 @@ export enum names { NAVIGATE_UP = 'up', NAVIGATE_DOWN = 'down', DISCONNECT = 'disconnect', + NEXT_STACK = 'next_stack', + PREVIOUS_STACK = 'previous_stack', } /** @@ -716,6 +719,75 @@ export function registerDisconnectBlock() { ShortcutRegistry.registry.register(disconnectShortcut); } +/** + * Registers keyboard shortcuts to jump between stacks/top-level items on the + * workspace. + */ +export function registerStackNavigation() { + /** + * Finds the stack root of the currently focused or specified item. + */ + const resolveStack = ( + workspace: WorkspaceSvg, + node = getFocusManager().getFocusedNode(), + ) => { + const navigator = workspace.getNavigator(); + + for ( + let parent: IFocusableNode | null = node; + parent && parent !== workspace; + parent = navigator.getParent(parent) + ) { + node = parent; + } + + if (!isSelectable(node)) return null; + + return node; + }; + + const nextStackShortcut: KeyboardShortcut = { + name: names.NEXT_STACK, + preconditionFn: (workspace) => + !workspace.isDragging() && !!resolveStack(workspace), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const start = resolveStack(workspace); + if (!start) return false; + const target = workspace.getNavigator().navigateStacks(start, 1); + if (!target) return false; + getFocusManager().focusNode(target); + return true; + }, + keyCodes: [KeyCodes.N], + }; + + const previousStackShortcut: KeyboardShortcut = { + name: names.PREVIOUS_STACK, + preconditionFn: (workspace) => + !workspace.isDragging() && !!resolveStack(workspace), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const start = resolveStack(workspace); + if (!start) return false; + // navigateStacks() returns the last connection in the stack when going + // backwards, but we want the root block, so resolve the stack from the + // element we get back. + const target = resolveStack( + workspace, + workspace.getNavigator().navigateStacks(start, -1), + ); + if (!target) return false; + getFocusManager().focusNode(target); + return true; + }, + keyCodes: [KeyCodes.B], + }; + + ShortcutRegistry.registry.register(nextStackShortcut); + ShortcutRegistry.registry.register(previousStackShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -743,6 +815,7 @@ export function registerKeyboardNavigationShortcuts() { registerFocusToolbox(); registerArrowNavigation(); registerDisconnectBlock(); + registerStackNavigation(); } registerDefaultShortcuts(); diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 44e1e42bf..c5b5dcb33 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -19,7 +19,7 @@ import {createKeyDownEvent} from './test_helpers/user_input.js'; suite('Keyboard Shortcut Items', function () { setup(function () { sharedTestSetup.call(this); - const toolbox = document.getElementById('toolbox-categories'); + const toolbox = document.getElementById('toolbox-test'); this.workspace = Blockly.inject('blocklyDiv', {toolbox}); this.injectionDiv = this.workspace.getInjectionDiv(); Blockly.ContextMenuRegistry.registry.reset(); @@ -799,4 +799,158 @@ suite('Keyboard Shortcut Items', function () { ); }); }); + + suite('Stack navigation (N / B)', function () { + const keyNextStack = () => createKeyDownEvent(Blockly.utils.KeyCodes.N); + const keyPrevStack = () => createKeyDownEvent(Blockly.utils.KeyCodes.B); + + setup(function () { + this.block1 = this.workspace.newBlock('controls_if'); + this.block2 = this.workspace.newBlock('stack_block'); + this.block3 = this.workspace.newBlock('stack_block'); + this.block2.moveBy(0, 100); + this.block3.moveBy(0, 400); + + this.comment1 = this.workspace.newComment(); + this.comment2 = this.workspace.newComment(); + this.comment1.moveBy(0, 200); + this.comment2.moveBy(0, 300); + }); + + test('First stack navigating back is a no-op', function () { + Blockly.getFocusManager().focusNode(this.block1); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block1, + ); + }); + + test('Last stack navigating forward is a no-op', function () { + Blockly.getFocusManager().focusNode(this.block3); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block3, + ); + }); + + test('Block forward to block', function () { + Blockly.getFocusManager().focusNode(this.block1); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); + + test('Block back to block', function () { + Blockly.getFocusManager().focusNode(this.block2); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block1, + ); + }); + + test('Block forward to workspace comment', function () { + Blockly.getFocusManager().focusNode(this.block2); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.comment1, + ); + }); + + test('Block back to workspace comment', function () { + Blockly.getFocusManager().focusNode(this.block3); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.comment2, + ); + }); + + test('Workspace comment forward to workspace comment', function () { + Blockly.getFocusManager().focusNode(this.comment1); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.comment2, + ); + }); + + test('Workspace comment back to workspace comment', function () { + Blockly.getFocusManager().focusNode(this.comment2); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.comment1, + ); + }); + + test('Workspace comment forward to block', function () { + Blockly.getFocusManager().focusNode(this.comment2); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block3, + ); + }); + + test('Workspace comment back to block', function () { + Blockly.getFocusManager().focusNode(this.comment1); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); + + test('Block forward to block in mutator workspace', async function () { + const icon = this.block1.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + this.clock.runAll(); + const mutatorWorkspace = icon.getWorkspace(); + const stack1 = mutatorWorkspace.newBlock('controls_if_elseif'); + const stack2 = mutatorWorkspace.newBlock('controls_if_elseif'); + stack1.initSvg(); + stack2.initSvg(); + stack1.render(); + stack2.render(); + stack1.moveBy(0, 100); + stack2.moveBy(0, 200); + Blockly.getFocusManager().focusNode(stack1); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), stack2); + }); + + test('Block back to block in mutator workspace', async function () { + const icon = this.block1.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + this.clock.runAll(); + const mutatorWorkspace = icon.getWorkspace(); + const stack1 = mutatorWorkspace.newBlock('controls_if_elseif'); + const stack2 = mutatorWorkspace.newBlock('controls_if_elseif'); + stack1.initSvg(); + stack2.initSvg(); + stack1.render(); + stack2.render(); + stack1.moveBy(0, 100); + stack2.moveBy(0, 200); + Blockly.getFocusManager().focusNode(stack2); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), stack1); + }); + + test('Next stack from nested element', async function () { + const icon = this.block1.getIcon(Blockly.icons.MutatorIcon.TYPE); + Blockly.getFocusManager().focusNode(icon); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); + }); }); From 34c265fcf87cc89a914e70e01879389f839d22f2 Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:06:11 -0400 Subject: [PATCH 029/200] feat!: announce toasts via shared ARIA live region (#9672) * feat: announce toasts via shared ARIA live region * chore: add extra space --- packages/blockly/core/toast.ts | 22 ++++--------- packages/blockly/tests/mocha/toast_test.js | 38 +++++++++++++++------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/blockly/core/toast.ts b/packages/blockly/core/toast.ts index 72559279f..12ffbb0d0 100644 --- a/packages/blockly/core/toast.ts +++ b/packages/blockly/core/toast.ts @@ -45,7 +45,7 @@ export interface ToastOptions { * How prominently/interrupting the readout of the toast should be for * screenreaders. Corresponds to aria-live and defaults to polite. */ - assertiveness?: Toast.Assertiveness; + assertiveness?: aria.LiveRegionAssertiveness; } /** @@ -89,15 +89,13 @@ export class Toast { const { message, duration = 5, - assertiveness = Toast.Assertiveness.POLITE, + assertiveness = aria.LiveRegionAssertiveness.POLITE, } = options; const toast = document.createElement('div'); workspace.getInjectionDiv().appendChild(toast); toast.dataset.toastId = options.id; toast.className = CLASS_NAME; - aria.setRole(toast, aria.Role.STATUS); - aria.setState(toast, aria.State.LIVE, assertiveness); const messageElement = toast.appendChild(document.createElement('div')); messageElement.className = MESSAGE_CLASS_NAME; @@ -157,6 +155,11 @@ export class Toast { toast.addEventListener('mouseleave', setToastTimeout); setToastTimeout(); + aria.announceDynamicAriaState(message, { + assertiveness, + role: aria.Role.STATUS, + }); + return toast; } @@ -174,17 +177,6 @@ export class Toast { } } -/** - * Options for how aggressively toasts should be read out by screenreaders. - * Values correspond to those for aria-live. - */ -export namespace Toast { - export enum Assertiveness { - ASSERTIVE = 'assertive', - POLITE = 'polite', - } -} - Css.register(` .${CLASS_NAME} { font-size: 1.2rem; diff --git a/packages/blockly/tests/mocha/toast_test.js b/packages/blockly/tests/mocha/toast_test.js index afb7f7f6c..a1d03da94 100644 --- a/packages/blockly/tests/mocha/toast_test.js +++ b/packages/blockly/tests/mocha/toast_test.js @@ -14,6 +14,7 @@ suite('Toasts', function () { setup(function () { sharedTestSetup.call(this); this.workspace = Blockly.inject('blocklyDiv', {}); + this.liveRegion = document.getElementById('blocklyAriaAnnounce'); this.toastIsVisible = (message) => { const toast = this.workspace .getInjectionDiv() @@ -97,16 +98,20 @@ suite('Toasts', function () { clock.restore(); }); - test('default to polite assertiveness', function () { + test('toast announces message with status role and polite assertiveness', function () { const message = 'texas toast'; Blockly.Toast.show(this.workspace, {message, id: 'test'}); - const toast = this.workspace - .getInjectionDiv() - .querySelector('.blocklyToast'); + this.clock.tick(11); + + assert.include(this.liveRegion.textContent, message); assert.equal( - toast.getAttribute('aria-live'), - Blockly.Toast.Assertiveness.POLITE, + this.liveRegion.getAttribute('role'), + Blockly.utils.aria.Role.STATUS, + ); + assert.equal( + this.liveRegion.getAttribute('aria-live'), + Blockly.utils.aria.LiveRegionAssertiveness.POLITE, ); }); @@ -115,15 +120,26 @@ suite('Toasts', function () { Blockly.Toast.show(this.workspace, { message, id: 'test', - assertiveness: Blockly.Toast.Assertiveness.ASSERTIVE, + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE, }); + + this.clock.tick(11); + + assert.equal( + this.liveRegion.getAttribute('aria-live'), + Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE, + ); + }); + + test('toast is not itself a live region', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message, id: 'test'}); + const toast = this.workspace .getInjectionDiv() .querySelector('.blocklyToast'); - assert.equal( - toast.getAttribute('aria-live'), - Blockly.Toast.Assertiveness.ASSERTIVE, - ); + assert.isNull(toast.getAttribute('aria-live')); + assert.notEqual(toast.getAttribute('role'), Blockly.utils.aria.Role.STATUS); }); }); From b49fe1ec7d76d1edcaa39860696410e50f1987b2 Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:54:11 -0400 Subject: [PATCH 030/200] feat: i shortcut on workspace gives overview (#9677) * feat: i shortcut on workspace gives overview * fix: code review changes --- packages/blockly/core/shortcut_items.ts | 80 ++++++++++++++++--- packages/blockly/core/utils/aria.ts | 1 - packages/blockly/msg/json/en.json | 9 ++- packages/blockly/msg/json/qqq.json | 7 +- packages/blockly/msg/messages.js | 24 +++++- .../tests/mocha/shortcut_items_test.js | 75 +++++++++++++++++ 6 files changed, 181 insertions(+), 15 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index b23b6d67b..43d1a7652 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -20,7 +20,9 @@ import {type IFocusableNode} from './interfaces/i_focusable_node.js'; import {isSelectable} from './interfaces/i_selectable.js'; import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import {keyboardNavigationController} from './keyboard_navigation_controller.js'; +import {Msg} from './msg.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'; @@ -56,6 +58,7 @@ export enum names { DISCONNECT = 'disconnect', NEXT_STACK = 'next_stack', PREVIOUS_STACK = 'previous_stack', + INFORMATION = 'information', } /** @@ -638,20 +641,20 @@ export function registerArrowNavigation() { } } +const resolveWorkspace = (workspace: WorkspaceSvg) => { + if (workspace.isFlyout) { + const target = workspace.targetWorkspace; + if (target) { + return resolveWorkspace(target); + } + } + return workspace.getRootWorkspace() ?? workspace; +}; + /** * Registers keyboard shortcut to focus the workspace. */ export function registerFocusWorkspace() { - const resolveWorkspace = (workspace: WorkspaceSvg) => { - if (workspace.isFlyout) { - const target = workspace.targetWorkspace; - if (target) { - return resolveWorkspace(target); - } - } - return workspace.getRootWorkspace() ?? workspace; - }; - const focusWorkspaceShortcut: KeyboardShortcut = { name: names.FOCUS_WORKSPACE, preconditionFn: (workspace) => !workspace.isDragging(), @@ -692,6 +695,55 @@ export function registerFocusToolbox() { ShortcutRegistry.registry.register(focusToolboxShortcut); } +/** + * Registers keyboard shortcut to get count of block stacks and comments. + */ +export function registerWorkspaceOverview() { + const shortcut: KeyboardShortcut = { + name: names.INFORMATION, + preconditionFn: (workspace, scope) => { + const focused = scope.focusedNode; + return focused === workspace; + }, + callback: (_workspace) => { + const workspace = resolveWorkspace(_workspace); + const stackCount = workspace.getTopBlocks().length; + const commentCount = workspace.getTopComments().length; + + // Build base string with block stack count. + let baseMsgKey; + if (stackCount === 0) { + baseMsgKey = 'WORKSPACE_CONTENTS_BLOCKS_ZERO'; + } else if (stackCount === 1) { + baseMsgKey = 'WORKSPACE_CONTENTS_BLOCKS_ONE'; + } else { + baseMsgKey = 'WORKSPACE_CONTENTS_BLOCKS_MANY'; + } + + // Build comment suffix. + let suffix = ''; + if (commentCount > 0) { + suffix = Msg[ + commentCount === 1 + ? 'WORKSPACE_CONTENTS_COMMENTS_ONE' + : 'WORKSPACE_CONTENTS_COMMENTS_MANY' + ].replace('%1', String(commentCount)); + } + + // Build final message. + const msg = Msg[baseMsgKey] + .replace('%1', String(stackCount)) + .replace('%2', suffix); + + aria.announceDynamicAriaState(msg); + + return true; + }, + keyCodes: [KeyCodes.I], + }; + ShortcutRegistry.registry.register(shortcut); +} + /** * Registers keyboard shortcut to disconnect the focused block. */ @@ -818,5 +870,13 @@ export function registerKeyboardNavigationShortcuts() { registerStackNavigation(); } +/** + * Registers keyboard shortcuts used to announce screen reader information. + */ +export function registerScreenReaderShortcuts() { + registerWorkspaceOverview(); +} + registerDefaultShortcuts(); registerKeyboardNavigationShortcuts(); +registerScreenReaderShortcuts(); diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index b324f8c60..6e50ff161 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -188,7 +188,6 @@ export function removeRole(element: Element) { */ export function setRole(element: Element, roleName: Role | null) { if (!roleName) { - console.log('Removing role from element', element, roleName); removeRole(element); } else { element.setAttribute(ROLE_ATTRIBUTE, roleName); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 814443756..1701adcfc 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-02-12 13:23:33.999357", + "lastupdated": "2026-04-03 10:36:19.846436", "locale": "en", "messagedocumentation" : "qqq" }, @@ -420,5 +420,10 @@ "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Hold %1 and use arrow keys to move freely, then %2 to accept the position", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position", "KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.", - "KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste." + "KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste.", + "WORKSPACE_CONTENTS_BLOCKS_MANY": "%1 stacks of blocks%2 in workspace.", + "WORKSPACE_CONTENTS_BLOCKS_ONE": "One stack of blocks%2 in workspace.", + "WORKSPACE_CONTENTS_BLOCKS_ZERO": "No blocks%2 in workspace.", + "WORKSPACE_CONTENTS_COMMENTS_MANY": " and %1 comments", + "WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 8e7be38b3..a8023b6e3 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -427,5 +427,10 @@ "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks to arbitrary locations with the keyboard.", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.", "KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.", - "KEYBOARD_NAV_CUT_HINT": "Message shown when an item is cut in keyboard navigation mode." + "KEYBOARD_NAV_CUT_HINT": "Message shown when an item is cut in keyboard navigation mode.", + "WORKSPACE_CONTENTS_BLOCKS_MANY": "ARIA live region message announcing the number of stacks of blocks in the workspace, optionally including comments. \n\nParameters:\n* %1 - the number of stacks (integer greater than 1)\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* '5 stacks of blocks in workspace.'\n* '5 stacks of blocks and 2 comments in workspace.'", + "WORKSPACE_CONTENTS_BLOCKS_ONE": "ARIA live region message announcing there is one stack of blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'One stack of blocks in workspace.'\n* 'One stack of blocks and 1 comment in workspace.'", + "WORKSPACE_CONTENTS_BLOCKS_ZERO": "ARIA live region message announcing there are no blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'No blocks in workspace.'\n* 'No blocks and 3 comments in workspace.'", + "WORKSPACE_CONTENTS_COMMENTS_MANY": "ARIA live region phrase appended when there are multiple workspace comments. \n\nParameters:\n* %1 - the number of comments (integer greater than 1)", + "WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 6ae66c40a..bc6316cfd 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1695,4 +1695,26 @@ Blockly.Msg.KEYBOARD_NAV_CONSTRAINED_MOVE_HINT = 'Use the arrow keys to move, th Blockly.Msg.KEYBOARD_NAV_COPIED_HINT = 'Copied. Press %1 to paste.'; /** @type {string} */ /// Message shown when an item is cut in keyboard navigation mode. -Blockly.Msg.KEYBOARD_NAV_CUT_HINT = 'Cut. Press %1 to paste.'; \ No newline at end of file +Blockly.Msg.KEYBOARD_NAV_CUT_HINT = 'Cut. Press %1 to paste.'; +/** @type {string} */ +/// ARIA live region message announcing the number of stacks of blocks in the workspace, optionally including comments. +/// \n\nParameters:\n* %1 - the number of stacks (integer greater than 1)\n* %2 - optional phrase announcing comments, including leading space +/// \n\nExamples:\n* "5 stacks of blocks in workspace."\n* "5 stacks of blocks and 2 comments in workspace." +Blockly.Msg.WORKSPACE_CONTENTS_BLOCKS_MANY = '%1 stacks of blocks%2 in workspace.'; +/** @type {string} */ +/// ARIA live region message announcing there is one stack of blocks in the workspace, optionally including a count of comments. +/// \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space +/// \n\nExamples:\n* "One stack of blocks in workspace."\n* "One stack of blocks and 1 comment in workspace." +Blockly.Msg.WORKSPACE_CONTENTS_BLOCKS_ONE = 'One stack of blocks%2 in workspace.'; +/** @type {string} */ +/// ARIA live region message announcing there are no blocks in the workspace, optionally including a count of comments. +/// \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space +/// \n\nExamples:\n* "No blocks in workspace."\n* "No blocks and 3 comments in workspace." +Blockly.Msg.WORKSPACE_CONTENTS_BLOCKS_ZERO = 'No blocks%2 in workspace.'; +/** @type {string} */ +/// ARIA live region phrase appended when there are multiple workspace comments. +/// \n\nParameters:\n* %1 - the number of comments (integer greater than 1) +Blockly.Msg.WORKSPACE_CONTENTS_COMMENTS_MANY = ' and %1 comments'; +/** @type {string} */ +/// ARIA live region phrase appended when there is exactly one workspace comment. +Blockly.Msg.WORKSPACE_CONTENTS_COMMENTS_ONE = ' and one comment'; diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index c5b5dcb33..41efab020 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -552,6 +552,81 @@ suite('Keyboard Shortcut Items', function () { }); }); + suite('Workspace Information (I)', function () { + setup(function () { + const keyEvent = createKeyDownEvent(Blockly.utils.KeyCodes.I); + // Helper to trigger the shortcut and assert the live region text. + this.assertAnnouncement = (expected) => { + this.injectionDiv.dispatchEvent(keyEvent); + // Wait for the live region to update after the event. + this.clock.tick(11); + // The announcement may include an additional non-breaking space. + assert.include(this.liveRegion.textContent, expected); + }; + this.liveRegion = document.getElementById('blocklyAriaAnnounce'); + }); + + test('Empty workspace', function () { + // Start with empty workspace. + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('No blocks in workspace.'); + }); + + test('One block', function () { + this.workspace.newBlock('stack_block'); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('One stack of blocks in workspace.'); + }); + + test('Two blocks', function () { + this.workspace.newBlock('stack_block'); + this.workspace.newBlock('stack_block'); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('2 stacks of blocks in workspace.'); + }); + + test('One comment', function () { + this.workspace.newComment(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('No blocks and one comment in workspace.'); + }); + + test('Two comments', function () { + this.workspace.newComment(); + this.workspace.newComment(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement('No blocks and 2 comments in workspace.'); + }); + + test('One block, one comment', function () { + this.workspace.newBlock('stack_block'); + this.workspace.newComment(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement( + 'One stack of blocks and one comment in workspace.', + ); + }); + + test('Two blocks, two comments', function () { + this.workspace.newBlock('stack_block'); + this.workspace.newBlock('stack_block'); + this.workspace.newComment(); + this.workspace.newComment(); + Blockly.getFocusManager().focusNode(this.workspace); + this.assertAnnouncement( + '2 stacks of blocks and 2 comments in workspace.', + ); + }); + + suite('Preconditions', function () { + test('Not called when focus is not on workspace', function () { + this.block = this.workspace.newBlock('stack_block'); + Blockly.getFocusManager().focusNode(this.block); + this.assertAnnouncement(''); + }); + }); + }); + suite('Focus Toolbox (T)', function () { setup(function () { Blockly.defineBlocksWithJsonArray([ From b665711646010d7af49e9a95feb79da335925cb8 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 7 Apr 2026 14:04:07 -0700 Subject: [PATCH 031/200] chore: Remove the build size check from `npm run test` (#9689) --- packages/blockly/scripts/gulpfiles/test_tasks.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/blockly/scripts/gulpfiles/test_tasks.mjs b/packages/blockly/scripts/gulpfiles/test_tasks.mjs index 37f988444..5d2b0dc56 100644 --- a/packages/blockly/scripts/gulpfiles/test_tasks.mjs +++ b/packages/blockly/scripts/gulpfiles/test_tasks.mjs @@ -394,7 +394,6 @@ const tasks = [ // Build must run before the remaining tasks build, renamings, - metadata, mocha, generators, node, From cb0d1c96ce6f5d0079c90f8b89f00f4d19eed897 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 7 Apr 2026 14:11:38 -0700 Subject: [PATCH 032/200] fix: Fix navigation between toolbox and flyout in all layouts (#9681) * fix: Fix navigation between toolbox and flyout in all layouts * test: Add tests --- .../navigators/flyout_navigator.ts | 105 ++++++- .../core/keyboard_nav/navigators/navigator.ts | 12 +- .../navigators/toolbox_navigator.ts | 139 +++++++++- packages/blockly/core/shortcut_items.ts | 12 +- .../tests/mocha/keyboard_navigation_test.js | 256 ++++++++++++++++++ 5 files changed, 503 insertions(+), 21 deletions(-) diff --git a/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts index 9e2cd8bdc..a49d18c04 100644 --- a/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts @@ -6,9 +6,11 @@ import {IFocusableNode} from '../../blockly.js'; import type {IFlyout} from '../../interfaces/i_flyout.js'; +import {Position} from '../../utils/toolbox.js'; import {FlyoutButtonNavigationPolicy} from '../navigation_policies/flyout_button_navigation_policy.js'; import {FlyoutSeparatorNavigationPolicy} from '../navigation_policies/flyout_separator_navigation_policy.js'; import {Navigator} from './navigator.js'; +import {getPhysicalToolboxPosition} from './toolbox_navigator.js'; /** * Navigator that handles keyboard navigation within a flyout. @@ -23,15 +25,114 @@ export class FlyoutNavigator extends Navigator { } /** - * Returns the toolbox when navigating to the left in a flyout. + * Returns the parent toolbox item or previous flyout item when navigating out + * (left arrow) from a flyout. + * + * @param node The flyout item to navigate relative to. + * @param bypassAdjustments True to skip adjusting navigation based on the + * flyout's layout; false (default) to take it into account. */ - override getOutNode(): IFocusableNode | null { + override getOutNode( + node?: IFocusableNode | null, + bypassAdjustments = false, + ): IFocusableNode | null { + if (!bypassAdjustments && this.flyout.targetWorkspace) { + const position = getPhysicalToolboxPosition(this.flyout.targetWorkspace); + switch (position) { + case Position.TOP: + case Position.BOTTOM: + return this.flyout.RTL + ? this.getNextNode(node, true) + : this.getPreviousNode(node, true); + case Position.RIGHT: + return null; + } + } + const toolbox = this.flyout.targetWorkspace?.getToolbox(); if (toolbox) return toolbox.getSelectedItem(); return null; } + /** + * Returns the parent toolbox item or next flyout item when navigating in + * (right arrow) from a flyout. + * + * @param node The flyout item to navigate relative to. + * @param bypassAdjustments True to skip adjusting navigation based on the + * flyout's layout; false (default) to take it into account. + */ + override getInNode( + node?: IFocusableNode | null, + bypassAdjustments = false, + ): IFocusableNode | null { + if (!bypassAdjustments && this.flyout.targetWorkspace) { + const position = getPhysicalToolboxPosition(this.flyout.targetWorkspace); + switch (position) { + case Position.TOP: + case Position.BOTTOM: + return this.flyout.RTL + ? this.getPreviousNode(node, true) + : this.getNextNode(node, true); + case Position.RIGHT: + return this.getOutNode(node, true); + } + } + + return super.getInNode(node); + } + + /** + * Returns the parent toolbox item or next flyout item when navigating next + * (down arrow) from a flyout. + * + * @param node The flyout item to navigate relative to. + * @param bypassAdjustments True to skip adjusting navigation based on the + * flyout's layout; false (default) to take it into account. + */ + override getNextNode( + node?: IFocusableNode | null, + bypassAdjustments = false, + ): IFocusableNode | null { + if (!bypassAdjustments && this.flyout.targetWorkspace) { + const position = getPhysicalToolboxPosition(this.flyout.targetWorkspace); + switch (position) { + case Position.TOP: + return null; + case Position.BOTTOM: + return this.getOutNode(node, true); + } + } + + return super.getNextNode(node); + } + + /** + * Returns the parent toolbox item or previous flyout item when navigating + * previous (up arrow) from a flyout. + * + * @param node The flyout item to navigate relative to. + * @param bypassAdjustments True to skip adjusting navigation based on the + * flyout's layout; false (default) to take it into account. + */ + override getPreviousNode( + node?: IFocusableNode | null, + bypassAdjustments = false, + ): IFocusableNode | null { + if (!bypassAdjustments && this.flyout.targetWorkspace) { + const position = getPhysicalToolboxPosition(this.flyout.targetWorkspace); + switch (position) { + case Position.TOP: + return this.getOutNode(node, true); + case Position.BOTTOM: + return null; + } + } + + return super.getPreviousNode(node); + } + /** * Returns a list of top-level navigable flyout items. */ diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index 1a66a965b..8621ed1e2 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -363,7 +363,7 @@ export class Navigator { const root = getFocusManager().getFocusedTree()?.getRootFocusableNode(); if (!root) return null; - return this.getFirstChild(root); + return this.getTopLevelItems(root)[0]; } /** @@ -372,12 +372,10 @@ export class Navigator { * @returns The last navigable node on the workspace, or null. */ getLastNode(): IFocusableNode | null { - const first = this.getFirstNode(); - const oldLooping = this.getNavigationLoops(); - this.setNavigationLoops(true); - const lastNode = this.getPreviousNode(first); - this.setNavigationLoops(oldLooping); - return lastNode; + const root = getFocusManager().getFocusedTree()?.getRootFocusableNode(); + if (!root) return null; + + return this.getTopLevelItems(root).slice(-1)[0]; } /** diff --git a/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts index 8b7238cf5..542ecca0b 100644 --- a/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts @@ -8,6 +8,8 @@ import {getFocusManager} from '../../focus_manager.js'; import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; import {isSelectableToolboxItem} from '../../interfaces/i_selectable_toolbox_item.js'; import type {IToolbox} from '../../interfaces/i_toolbox.js'; +import {Position} from '../../utils/toolbox.js'; +import type {WorkspaceSvg} from '../../workspace_svg.js'; import {ToolboxItemNavigationPolicy} from '../navigation_policies/toolbox_item_navigation_policy.js'; import {Navigator} from './navigator.js'; @@ -21,20 +23,118 @@ export class ToolboxNavigator extends Navigator { } /** - * Returns the flyout's first item when navigating to the right in a toolbox - * from a toolbox item that has a flyout. + * Returns the flyout's first item (if any) or next toolbox item when + * navigating in (right arrow) from a toolbox. + * + * @param node The toolbox item to navigate relative to. + * @param bypassAdjustments True to skip adjusting navigation based on the + * toolbox's layout; false (default) to take it into account. */ override getInNode( - current = getFocusManager().getFocusedNode(), + node = getFocusManager().getFocusedNode(), + bypassAdjustments = false, ): IFocusableNode | null { - if (isSelectableToolboxItem(current) && !current.getContents().length) { + const position = getPhysicalToolboxPosition(this.toolbox.getWorkspace()); + if (!bypassAdjustments) { + switch (position) { + case Position.TOP: + case Position.BOTTOM: + return this.getNextNode(node, true); + case Position.RIGHT: + return this.getOutNode(node, true); + } + } + + if (isSelectableToolboxItem(node) && !node.getContents().length) { return null; } - return ( - this.toolbox.getFlyout()?.getWorkspace().getRestoredFocusableNode(null) ?? - null - ); + const flyoutNavigator = this.toolbox + .getFlyout() + ?.getWorkspace() + .getNavigator(); + if (!flyoutNavigator) return null; + + return this.toolbox.getWorkspace().RTL && + (position === Position.TOP || position === Position.BOTTOM) + ? flyoutNavigator.getLastNode() + : flyoutNavigator.getFirstNode(); + } + + /** + * Returns the flyout's first item (if any) or previous toolbox item when + * navigating out (left arrow) from a toolbox. + * + * @param node The toolbox item to navigate relative to. + * @param bypassAdjustments True to skip adjusting navigation based on the + * toolbox's layout; false (default) to take it into account. + */ + override getOutNode( + node?: IFocusableNode | null, + bypassAdjustments = false, + ): IFocusableNode | null { + if (!bypassAdjustments) { + const position = getPhysicalToolboxPosition(this.toolbox.getWorkspace()); + switch (position) { + case Position.TOP: + case Position.BOTTOM: + return this.getPreviousNode(node, true); + case Position.RIGHT: + return this.getInNode(node, true); + } + } + + return super.getOutNode(node); + } + + /** + * Returns the flyout's first item (if any) or next toolbox item when + * navigating next (down arrow) from a toolbox. + * + * @param node The toolbox item to navigate relative to. + * @param bypassAdjustments True to skip adjusting navigation based on the + * toolbox's layout; false (default) to take it into account. + */ + override getNextNode( + node?: IFocusableNode | null, + bypassAdjustments = false, + ): IFocusableNode | null { + if (!bypassAdjustments) { + const position = getPhysicalToolboxPosition(this.toolbox.getWorkspace()); + switch (position) { + case Position.TOP: + return this.getInNode(node, true); + case Position.BOTTOM: + return this.getOutNode(node, true); + } + } + + return super.getNextNode(node); + } + + /** + * Returns the flyout's first item (if any) or previous toolbox item when + * navigating previous (up arrow) from a toolbox. + * + * @param node The toolbox item to navigate relative to. + * @param bypassAdjustments True to skip adjusting navigation based on the + * toolbox's layout; false (default) to take it into account. + */ + override getPreviousNode( + node?: IFocusableNode | null, + bypassAdjustments = false, + ): IFocusableNode | null { + if (!bypassAdjustments) { + const position = getPhysicalToolboxPosition(this.toolbox.getWorkspace()); + switch (position) { + case Position.TOP: + return this.getOutNode(node, true); + case Position.BOTTOM: + return this.getInNode(node, true); + } + } + + return super.getPreviousNode(node); } /** @@ -44,3 +144,26 @@ export class ToolboxNavigator extends Navigator { return this.toolbox.getToolboxItems(); } } + +/** + * Although developers specify the toolbox position as "start" or "end", this + * gets normalized by the injection options parser based on RTL, such that "end" + * in RTL means the left. When dealing with arrow keys, we want the actual/ + * physical position on screen, not the logical position. This function converts + * the stored logical position to the physical position. + * + * @internal + * @param workspace The workspace to use injection options from. + * @returns The physical location of the toolbox/flyout on screen. + */ +export function getPhysicalToolboxPosition(workspace: WorkspaceSvg): Position { + const logicalPosition = workspace.options.toolboxPosition; + if ( + workspace.options.RTL && + !(logicalPosition === Position.TOP || logicalPosition === Position.BOTTOM) + ) { + return logicalPosition === Position.LEFT ? Position.RIGHT : Position.LEFT; + } + + return logicalPosition; +} diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 43d1a7652..29120c0dc 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -570,7 +570,8 @@ export function registerArrowNavigation() { right: { name: names.NAVIGATE_RIGHT, preconditionFn: (workspace) => !workspace.isDragging(), - callback: (workspace) => { + callback: (workspace, e) => { + e.preventDefault(); keyboardNavigationController.setIsActive(true); const node = workspace.RTL ? getFocusManager().getFocusedTree()?.getNavigator().getOutNode() @@ -587,7 +588,8 @@ export function registerArrowNavigation() { left: { name: names.NAVIGATE_LEFT, preconditionFn: (workspace) => !workspace.isDragging(), - callback: (workspace) => { + callback: (workspace, e) => { + e.preventDefault(); keyboardNavigationController.setIsActive(true); const node = workspace.RTL ? getFocusManager().getFocusedTree()?.getNavigator().getInNode() @@ -604,7 +606,8 @@ export function registerArrowNavigation() { down: { name: names.NAVIGATE_DOWN, preconditionFn: (workspace) => !workspace.isDragging(), - callback: () => { + callback: (_workspace, e) => { + e.preventDefault(); keyboardNavigationController.setIsActive(true); const node = getFocusManager() .getFocusedTree() @@ -621,7 +624,8 @@ export function registerArrowNavigation() { up: { name: names.NAVIGATE_UP, preconditionFn: (workspace) => !workspace.isDragging(), - callback: () => { + callback: (_workspace, e) => { + e.preventDefault(); keyboardNavigationController.setIsActive(true); const node = getFocusManager() .getFocusedTree() diff --git a/packages/blockly/tests/mocha/keyboard_navigation_test.js b/packages/blockly/tests/mocha/keyboard_navigation_test.js index e3491dca5..9d5614ca9 100644 --- a/packages/blockly/tests/mocha/keyboard_navigation_test.js +++ b/packages/blockly/tests/mocha/keyboard_navigation_test.js @@ -401,3 +401,259 @@ suite('Workspace comment navigation', function () { assert.equal(getFocusNodeId(), this.commentId1); }); }); + +const leftColumnNav = { + in: Blockly.utils.KeyCodes.RIGHT, + out: Blockly.utils.KeyCodes.LEFT, + nextItem: Blockly.utils.KeyCodes.DOWN, + previousItem: Blockly.utils.KeyCodes.UP, +}; + +const rightColumnNav = { + in: Blockly.utils.KeyCodes.LEFT, + out: Blockly.utils.KeyCodes.RIGHT, + nextItem: Blockly.utils.KeyCodes.DOWN, + previousItem: Blockly.utils.KeyCodes.UP, +}; + +/** + * All possible combinations of horizontal/vertical layout, LTR/RTL, and start/ + * end toolbox/flyout positioning, along with the keycodes that should navigate + * in, out, and to the previous/next item in that layout configuration. + */ +const TOOLBOX_FLYOUT_LAYOUTS = [ + { + id: 'Vertical Start LTR', + rtl: false, + horizontalLayout: false, + toolboxPosition: 'start', + ...leftColumnNav, + }, + { + id: 'Vertical Start RTL', + rtl: true, + horizontalLayout: false, + toolboxPosition: 'start', + ...rightColumnNav, + }, + { + id: 'Vertical End LTR', + rtl: false, + horizontalLayout: false, + toolboxPosition: 'end', + ...rightColumnNav, + }, + { + id: 'Vertical End RTL', + rtl: true, + horizontalLayout: false, + toolboxPosition: 'end', + ...leftColumnNav, + }, + { + id: 'Horizontal Start LTR', + rtl: false, + horizontalLayout: true, + toolboxPosition: 'start', + in: Blockly.utils.KeyCodes.DOWN, + out: Blockly.utils.KeyCodes.UP, + nextItem: Blockly.utils.KeyCodes.RIGHT, + previousItem: Blockly.utils.KeyCodes.LEFT, + }, + { + id: 'Horizontal Start RTL', + rtl: true, + horizontalLayout: true, + toolboxPosition: 'start', + in: Blockly.utils.KeyCodes.DOWN, + out: Blockly.utils.KeyCodes.UP, + nextItem: Blockly.utils.KeyCodes.LEFT, + previousItem: Blockly.utils.KeyCodes.RIGHT, + }, + { + id: 'Horizontal End LTR', + rtl: false, + horizontalLayout: true, + toolboxPosition: 'end', + in: Blockly.utils.KeyCodes.UP, + out: Blockly.utils.KeyCodes.DOWN, + nextItem: Blockly.utils.KeyCodes.RIGHT, + previousItem: Blockly.utils.KeyCodes.LEFT, + }, + { + id: 'Horizontal End RTL', + rtl: true, + horizontalLayout: true, + toolboxPosition: 'end', + in: Blockly.utils.KeyCodes.UP, + out: Blockly.utils.KeyCodes.DOWN, + nextItem: Blockly.utils.KeyCodes.LEFT, + previousItem: Blockly.utils.KeyCodes.RIGHT, + }, +]; + +suite('Toolbox and flyout arrow navigation by layout', function () { + for (const layout of TOOLBOX_FLYOUT_LAYOUTS) { + suite(layout.id, function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + type: 'basic_block', + message0: '%1', + args0: [ + { + type: 'field_input', + name: 'TEXT', + text: 'default', + }, + ], + }, + ]); + const toolbox = document.getElementById('toolbox-categories'); + this.workspace = Blockly.inject('blocklyDiv', { + toolbox, + rtl: layout.rtl, + horizontalLayout: layout.horizontalLayout, + toolboxPosition: layout.toolboxPosition, + renderer: 'zelos', + }); + this.keys = layout; + this.firstToolboxItem = this.workspace + .getToolbox() + .getToolboxItems()[0]; + this.lastToolboxItem = this.workspace.getToolbox().getToolboxItems()[1]; + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('Previous toolbox item from first is no-op', function () { + Blockly.getFocusManager().focusNode(this.firstToolboxItem); + pressKey(this.workspace, this.keys.previousItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.firstToolboxItem, + ); + }); + + test('Previous toolbox item', function () { + Blockly.getFocusManager().focusNode(this.lastToolboxItem); + pressKey(this.workspace, this.keys.previousItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.firstToolboxItem, + ); + }); + + test('Next toolbox item from last is no-op', function () { + Blockly.getFocusManager().focusNode(this.lastToolboxItem); + pressKey(this.workspace, this.keys.nextItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.lastToolboxItem, + ); + }); + + test('Next toolbox item', function () { + Blockly.getFocusManager().focusNode(this.firstToolboxItem); + pressKey(this.workspace, this.keys.nextItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.lastToolboxItem, + ); + }); + + test('Out from toolbox item is no-op', function () { + Blockly.getFocusManager().focusNode(this.firstToolboxItem); + pressKey(this.workspace, this.keys.out); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.firstToolboxItem, + ); + }); + + test('In from toolbox item focuses first flyout item', function () { + Blockly.getFocusManager().focusNode(this.firstToolboxItem); + pressKey(this.workspace, this.keys.in); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + }); + + test('Previous flyout item from first is no-op', function () { + pressKey(this.workspace, Blockly.utils.KeyCodes.T); + Blockly.getFocusManager().focusNode( + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + pressKey(this.workspace, this.keys.previousItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + }); + + test('Previous flyout item', function () { + pressKey(this.workspace, Blockly.utils.KeyCodes.T); + Blockly.getFocusManager().focusNode( + this.workspace.getFlyout().getWorkspace().getTopBlocks()[1], + ); + pressKey(this.workspace, this.keys.previousItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + }); + + test('Next flyout item from last is no-op', function () { + pressKey(this.workspace, Blockly.utils.KeyCodes.T); + Blockly.getFocusManager().focusNode( + this.workspace.getFlyout().getWorkspace().getTopBlocks()[1], + ); + pressKey(this.workspace, this.keys.nextItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getFlyout().getWorkspace().getTopBlocks()[1], + ); + }); + + test('Next flyout item', function () { + pressKey(this.workspace, Blockly.utils.KeyCodes.T); + Blockly.getFocusManager().focusNode( + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + pressKey(this.workspace, this.keys.nextItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getFlyout().getWorkspace().getTopBlocks()[1], + ); + }); + + test('Out from flyout item focuses toolbox item', function () { + pressKey(this.workspace, Blockly.utils.KeyCodes.T); + Blockly.getFocusManager().focusNode( + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + pressKey(this.workspace, this.keys.out); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.firstToolboxItem, + ); + }); + + test('In from flyout item is no-op', function () { + pressKey(this.workspace, Blockly.utils.KeyCodes.T); + Blockly.getFocusManager().focusNode( + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + pressKey(this.workspace, this.keys.in); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + }); + }); + } +}); From 9d5307cc375819e0075e0afba18ea13ada721470 Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:00:36 -0400 Subject: [PATCH 033/200] feat: Add aria APIs to Field base class (#9683) * feat: Add aria APIs to Field base class * fix: no underscores in new code --- packages/blockly/core/field.ts | 89 +++++++++++++++ packages/blockly/tests/mocha/field_test.js | 127 +++++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index e025efab7..2ecef1681 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -98,6 +98,9 @@ export abstract class Field /** Validation function called when user edits an editable field. */ protected validator_: FieldValidator | null = null; + /** The ARIA-friendly label representation of this field's type. */ + protected ariaTypeName: string | null = null; + /** * Used to cache the field's tooltip value if setTooltip is called when the * field is not yet initialized. Is *not* guaranteed to be accurate. @@ -250,6 +253,9 @@ export abstract class Field if (config.tooltip) { this.setTooltip(parsing.replaceMessageReferences(config.tooltip)); } + if (config.ariaTypeName) { + this.ariaTypeName = config.ariaTypeName; + } } /** @@ -300,6 +306,88 @@ export abstract class Field return this.sourceBlock_; } + /** + * Gets an ARIA-friendly label representation of this field's type. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's type. + * + * @returns An ARIA representation of the field's type or null if it is + * unspecified. + */ + getAriaTypeName(): string | null { + return this.ariaTypeName; + } + + /** + * Gets 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. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's value. + * + * @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 value = this.getValue(); + + if (value === null || value === undefined) { + return null; + } + + return String(value); + } + + /** + * 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 contexts 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 can + * lead to the field being potentially COMPLETELY HIDDEN for screen reader + * navigation so it's crucial for implementations to ensure a non-empty value + * is returned here. + * + * @param includeTypeInfo Whether to include the field's type information in + * the returned label, if available. + */ + computeAriaLabel(includeTypeInfo: boolean = false): string { + const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null; + const ariaValue = this.getAriaValue(); + + if (!ariaTypeName && !ariaValue) { + return ''; + } + + if (ariaTypeName && ariaValue) { + return `${ariaTypeName}: ${ariaValue}`; + } + + return ariaTypeName ?? ariaValue ?? ''; + } + /** * Initialize everything to render this field. Override * methods initModel and initView rather than this method. @@ -1417,6 +1505,7 @@ export abstract class Field */ export interface FieldConfig { tooltip?: string; + ariaTypeName?: string; } /** diff --git a/packages/blockly/tests/mocha/field_test.js b/packages/blockly/tests/mocha/field_test.js index 422b04734..e2163a2a3 100644 --- a/packages/blockly/tests/mocha/field_test.js +++ b/packages/blockly/tests/mocha/field_test.js @@ -818,4 +818,131 @@ suite('Abstract Fields', function () { }); }); }); + + suite('Aria', function () { + class TestField extends Blockly.Field { + constructor(value, config = undefined) { + super(value, null, config); + } + } + + suite('getAriaTypeName', function () { + test('Default returns null', function () { + const field = new TestField(); + assert.isNull(field.getAriaTypeName()); + }); + + test('Returns configured ariaTypeName (JS)', function () { + const field = new TestField('value', {ariaTypeName: 'number'}); + assert.equal(field.getAriaTypeName(), 'number'); + }); + + test('Returns configured ariaTypeName (JSON)', function () { + class CustomField extends Blockly.Field { + constructor(opt_config) { + super('value', null, opt_config); + } + + static fromJson(options) { + return new CustomField(options); + } + } + + const field = CustomField.fromJson({ariaTypeName: 'text input'}); + assert.equal(field.getAriaTypeName(), 'text input'); + }); + }); + + suite('getAriaValue', function () { + test('Returns string value', function () { + const field = new TestField('hello'); + assert.equal(field.getAriaValue(), 'hello'); + }); + + test('Returns stringified number', function () { + const field = new TestField(123); + assert.equal(field.getAriaValue(), '123'); + }); + + test('Returns null for null value', function () { + const field = new TestField(null); + assert.isNull(field.getAriaValue()); + }); + + test('Returns null for undefined value', function () { + const field = new TestField(undefined); + assert.isNull(field.getAriaValue()); + }); + }); + + suite('computeAriaLabel', function () { + test('Value only (default)', function () { + const field = new TestField('hello'); + assert.equal(field.computeAriaLabel(), 'hello'); + }); + + test('Value only when includeTypeInfo=false', function () { + const field = new TestField('hello', {ariaTypeName: 'text'}); + assert.equal(field.computeAriaLabel(false), 'hello'); + }); + + test('Type and value when includeTypeInfo=true', function () { + const field = new TestField('hello', {ariaTypeName: 'text'}); + assert.equal(field.computeAriaLabel(true), 'text: hello'); + }); + + test('Type only when value is null', function () { + const field = new TestField(null, {ariaTypeName: 'text'}); + assert.equal(field.computeAriaLabel(true), 'text'); + }); + + test('Empty string when no type or value', function () { + const field = new TestField(null); + assert.equal(field.computeAriaLabel(true), ''); + }); + + test('Handles missing type with includeTypeInfo=true', function () { + const field = new TestField('hello'); + assert.equal(field.computeAriaLabel(true), 'hello'); + }); + }); + + suite('Subclass overrides', function () { + class CustomValueField extends TestField { + getAriaValue() { + return 'custom value'; + } + } + + class CustomTypeField extends TestField { + getAriaTypeName() { + return 'custom type'; + } + } + + class FullCustomField extends TestField { + getAriaValue() { + return 'custom value'; + } + getAriaTypeName() { + return 'custom type'; + } + } + + test('Uses overridden getAriaValue', function () { + const field = new CustomValueField('ignored'); + assert.equal(field.computeAriaLabel(), 'custom value'); + }); + + test('Uses overridden getAriaTypeName', function () { + const field = new CustomTypeField('value'); + assert.equal(field.computeAriaLabel(true), 'custom type: value'); + }); + + test('Uses both overrides', function () { + const field = new FullCustomField(); + assert.equal(field.computeAriaLabel(true), 'custom type: custom value'); + }); + }); + }); }); From b4d21b2f9487d8dec63d86bdc42ca9148782a2cb Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:38:20 -0400 Subject: [PATCH 034/200] feat: aria utility additions / improvements (#9690) --- packages/blockly/core/utils/aria.ts | 357 ++++++++++++++------ packages/blockly/tests/mocha/aria_test.js | 377 +++++++++++++--------- 2 files changed, 484 insertions(+), 250 deletions(-) diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 6e50ff161..5837adead 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -51,119 +51,229 @@ export interface DynamicAnnouncementOptions { } /** - * ARIA role values. - * Copied from Closure's goog.a11y.aria.Role + * A valid ARIA role for a Blockly DOM element. See also setRole() and getRole(). + * + * This should be used instead of directly setting an element's role attribute. */ export enum Role { - // ARIA role for an interactive control of tabular data. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/application_role. */ + APPLICATION = 'application', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/button_role. */ + BUTTON = 'button', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/checkbox_role. */ + CHECKBOX = 'checkbox', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/dialog_role. */ + DIALOG = 'dialog', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/figure_role. */ + FIGURE = 'figure', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/generic_role. */ + GENERIC = 'generic', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/grid_role. */ GRID = 'grid', - - // ARIA role for a cell in a grid. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/gridcell_role. */ GRIDCELL = 'gridcell', - // ARIA role for a group of related elements like tree item siblings. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/group_role. */ GROUP = 'group', - - // ARIA role for a listbox. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/list_role. */ + LIST = 'list', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/listbox_role. */ LISTBOX = 'listbox', - - // ARIA role for a popup menu. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/listitem_role. */ + LISTITEM = 'listitem', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menu_role. */ MENU = 'menu', - - // ARIA role for menu item elements. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menuitem_role. */ 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. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/option_role. */ OPTION = 'option', - // ARIA role for ignorable cosmetic elements with no semantic significance. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/presentation_role. */ PRESENTATION = 'presentation', - - // ARIA role for a row of cells in a grid. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/region_role. */ + REGION = 'region', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/row_role. */ ROW = 'row', - // ARIA role for a tree. - TREE = 'tree', - - // ARIA role for a tree item that sometimes may be expanded or collapsed. - TREEITEM = 'treeitem', - - // ARIA role for a visual separator in e.g. a menu. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/separator_role. */ SEPARATOR = 'separator', - - // ARIA role for a live region providing information. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role. */ STATUS = 'status', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/textbox_role. */ + TEXTBOX = 'textbox', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/tree_role. */ + TREE = 'tree', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/treeitem_role. */ + TREEITEM = 'treeitem', } const DEFAULT_LIVE_REGION_ROLE = Role.STATUS; /** - * ARIA states and properties. - * Copied from Closure's goog.a11y.aria.State + * A possible ARIA attribute state for a Blockly DOM element. See also setState() and getState(). + * + * This should be used instead of directly setting aria-* attributes on elements. */ 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. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-activedescendant. + * + * Value: ID of a DOM element. + */ ACTIVEDESCENDANT = 'activedescendant', - // ARIA property that, if true, indicates that all of a changed region should - // be presented, instead of only parts. Value: one of {true, false}. - ATOMIC = 'atomic', - // ARIA property defines the total number of columns in a table, grid, or - // treegrid. - // Value: integer. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-atomic. + * + * Value: one of {true, false}. + */ + ATOMIC = 'ATOMIC', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-checked. + * + * Value: one of {true, false, mixed, undefined}. + */ + CHECKED = 'checked', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-colcount. + * + * Value: an integer representing the number of columns in a grid. + */ COLCOUNT = 'colcount', - // ARIA state for a disabled item. Value: one of {true, false}. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-controls. + * + * Value: an array of element IDs. + */ + CONTROLS = 'controls', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-disabled. + * + * Value: one of {true, false}. + */ DISABLED = 'disabled', - - // ARIA state for setting whether the element like a tree node is expanded. - // Value: one of {true, false, undefined}. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-expanded. + * + * Value: one of {true, false, undefined}. + */ EXPANDED = 'expanded', - - // ARIA state indicating that the entered value does not conform. Value: - // one of {false, true, 'grammar', 'spelling'} - INVALID = 'invalid', - - // ARIA property that provides a label to override any other text, value, or - // contents used to describe this element. Value: string. - LABEL = 'label', - // ARIA property for setting the element which labels another element. - // Value: space-separated IDs of elements. - LABELLEDBY = 'labelledby', - - // 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', - // ARIA property defining the number of items in a list. Value: integer. - SETSIZE = 'setsize', - - // ARIA property for slider maximum value. Value: number. - VALUEMAX = 'valuemax', - - // ARIA property for slider minimum value. Value: number. - VALUEMIN = 'valuemin', - - // ARIA property for live region chattiness. - // Value: one of {polite, assertive, off}. - LIVE = 'live', - - // ARIA property for removing elements from the accessibility tree. - // Value: one of {true, false, undefined}. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-haspopup. + * + * Value: one of {true, false, menu, listbox, tree, grid, dialog}. + */ + HASPOPUP = 'haspopup', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden. + * + * Value: one of {true, false,undefined}. + */ HIDDEN = 'hidden', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid. + * + * Value: one of {true, false, grammar, spelling}. + */ + INVALID = 'invalid', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-label. + * + * Value: a string. + */ + LABEL = 'label', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-labelledby. + * + * Value: an array of element IDs. + */ + LABELLEDBY = 'labelledby', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-level. + * + * Value: an integer. + */ + LEVEL = 'level', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live. + * + * Value: one of {polite, assertive, off}. + */ + LIVE = 'live', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-orientation. + * + * Value: one of {horizontal, vertical, undefined}. + */ + ORIENTATION = 'orientation', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-posinset. + * + * Value: an integer representing the position of the element within a set of related elements. + */ + POSINSET = 'posinset', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed. + * + * Value: one of {true, false, mixed, undefined}. + */ + PRESSED = 'pressed', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-readonly. + * + * Value: one of {true, false}. + */ + READONLY = 'readonly', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-required. + * + * Value: one of {true, false}. + */ + REQUIRED = 'required', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-roledescription. + * + * Value: a string. + */ + ROLEDESCRIPTION = 'roledescription', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowcount. + * + * Value: an integer representing the number of rows in a grid or table. + */ + ROWCOUNT = 'rowcount', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowindex. + * + * Value: an integer representing the index of the element within a set of related elements. + */ + ROWINDEX = 'rowindex', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowspan. + * + * Value: an integer representing the number of rows a cell spans in a grid or table. + */ + ROWSPAN = 'rowspan', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-selected. + * + * Value:one of {true, false, undefined}. + */ + SELECTED = 'selected', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-setsize. + * + * Value: an integer representing the total number of elements in a set of related elements. + */ + SETSIZE = 'setsize', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-valuemax. + * + * Value: a number representing the maximum allowed value for a range widget. + */ + VALUEMAX = 'valuemax', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-valuemin. + * + * Value: a number representing the minimum allowed value for a range widget. + */ + VALUEMIN = 'valuemin', } /** @@ -178,13 +288,11 @@ export function removeRole(element: Element) { } /** - * Sets the ARIA role of an element. If `roleName` is null, - * the role is removed. + * Updates the specific role for the specified element. * - * Similar to Closure's goog.a11y.aria.setRole - * - * @param element DOM node to set role of. - * @param roleName Role name, or null to remove the role. + * @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) { @@ -195,14 +303,31 @@ export function setRole(element: Element, roleName: Role | null) { } /** - * Sets the state or property of an element. - * Copied from Closure's goog.a11y.aria + * 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 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. + * @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 getRole(element: Element): Role | null { + const role = element.getAttribute(ROLE_ATTRIBUTE); + if (role && Object.values(Role).includes(role as Role)) { + return role as Role; + } + return null; +} + +/** + * Sets the specified ARIA state by its name and value for the specified + * element. + * + * 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, @@ -216,6 +341,36 @@ 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) { + const attrStateName = ARIA_PREFIX + stateName; + element.removeAttribute(attrStateName); +} + +/** + * 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; + const value = element.getAttribute(attrStateName); + return value ? value : null; +} + let liveRegionElement: HTMLElement | null = null; /** diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 960f8e69b..806b5d7c0 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -21,167 +21,246 @@ suite('Aria', function () { sharedTestTeardown.call(this); }); - test('live region is created', function () { - assert.isNotNull(this.liveRegion); - }); - - test('live region has polite aria-live', function () { - assert.equal(this.liveRegion.getAttribute('aria-live'), 'polite'); - }); - - test('live region has atomic true', function () { - assert.equal(this.liveRegion.getAttribute('aria-atomic'), 'true'); - }); - - test('live region has status role by default', function () { - assert.equal(this.liveRegion.getAttribute('role'), 'status'); - }); - - test('live region is rendered for screen readers but visually hidden', function () { - const style = window.getComputedStyle(this.liveRegion); - - // Still rendered for screen readers - assert.notEqual(style.display, 'none'); - - // Visually hidden via hiddenForAria class - assert.equal(style.position, 'absolute'); - assert.equal(style.left, '-9999px'); - assert.equal(style.width, '1px'); - assert.equal(style.height, '1px'); - assert.equal(style.overflow, 'hidden'); - }); - - test('createLiveRegion only creates one region (singleton)', function () { - // Calling again should not create a duplicate. - Blockly.utils.aria.initializeGlobalAriaLiveRegion( - this.workspace.getInjectionDiv(), - ); - - const regions = this.workspace - .getInjectionDiv() - .querySelectorAll('#blocklyAriaAnnounce'); - - assert.equal(regions.length, 1); - }); - - test('announcement is delayed', function () { - Blockly.utils.aria.announceDynamicAriaState('Hello world'); - - assert.equal(this.liveRegion.textContent, ''); - - // Advance past the delay in announceDynamicAriaState. - this.clock.tick(11); - assert.include(this.liveRegion.textContent, 'Hello world'); - }); - - test('repeated announcements are unique', function () { - Blockly.utils.aria.announceDynamicAriaState('Block moved'); - this.clock.tick(11); - - const first = this.liveRegion.textContent; - - Blockly.utils.aria.announceDynamicAriaState('Block moved'); - this.clock.tick(11); - - const second = this.liveRegion.textContent; - - assert.notEqual(first, second); - }); - - test('last write wins when called rapidly', function () { - Blockly.utils.aria.announceDynamicAriaState('First message'); - Blockly.utils.aria.announceDynamicAriaState('Second message'); - Blockly.utils.aria.announceDynamicAriaState('Final message'); - - this.clock.tick(11); - - assert.include(this.liveRegion.textContent, 'Final message'); - }); - - test('assertive option sets aria-live assertive', function () { - Blockly.utils.aria.announceDynamicAriaState('Warning', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE, - role: null, + suite('Live Region', function () { + test('live region is created', function () { + assert.isNotNull(this.liveRegion); }); - this.clock.tick(11); - - assert.equal(this.liveRegion.getAttribute('aria-live'), 'assertive'); - }); - - test('role option updates role attribute', function () { - Blockly.utils.aria.announceDynamicAriaState('Alert message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.GROUP, + test('live region has polite aria-live', function () { + assert.equal(this.liveRegion.getAttribute('aria-live'), 'polite'); }); - this.clock.tick(11); - - assert.equal(this.liveRegion.getAttribute('role'), 'group'); - }); - - test('role and text update after delay', function () { - // Initial announcement to establish baseline role + text. - Blockly.utils.aria.announceDynamicAriaState('Initial message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.STATUS, - }); - this.clock.tick(11); - - assert.equal(this.liveRegion.getAttribute('role'), 'status'); - const initialText = this.liveRegion.textContent; - - // Now announce with different role. - Blockly.utils.aria.announceDynamicAriaState('Group message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.GROUP, + test('live region has atomic true', function () { + assert.equal(this.liveRegion.getAttribute('aria-atomic'), 'true'); }); - // Before delay: role and text should not have changed yet. - this.clock.tick(5); - assert.equal(this.liveRegion.getAttribute('role'), 'status'); - assert.equal(this.liveRegion.textContent, initialText); - - // After delay: both should update. - this.clock.tick(6); - assert.equal(this.liveRegion.getAttribute('role'), 'group'); - assert.include(this.liveRegion.textContent, 'Group message'); - }); - test('missing role does not clear default status role', function () { - Blockly.utils.aria.announceDynamicAriaState('Hello world'); - - this.clock.tick(11); - - assert.equal(this.liveRegion.getAttribute('role'), 'status'); - }); - test('custom role overrides default status role', function () { - Blockly.utils.aria.announceDynamicAriaState('Group message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.GROUP, + test('live region has status role by default', function () { + assert.equal(this.liveRegion.getAttribute('role'), 'status'); }); - this.clock.tick(11); + test('live region is rendered for screen readers but visually hidden', function () { + const style = window.getComputedStyle(this.liveRegion); - assert.equal(this.liveRegion.getAttribute('role'), 'group'); - }); - test('role reverts to status after custom role when role not provided', function () { - // First: default - Blockly.utils.aria.announceDynamicAriaState('Normal message'); - this.clock.tick(11); - assert.equal(this.liveRegion.getAttribute('role'), 'status'); + // Still rendered for screen readers + assert.notEqual(style.display, 'none'); - // Second: custom role - Blockly.utils.aria.announceDynamicAriaState('Group message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.GROUP, + // Visually hidden via hiddenForAria class + assert.equal(style.position, 'absolute'); + assert.equal(style.left, '-9999px'); + assert.equal(style.width, '1px'); + assert.equal(style.height, '1px'); + assert.equal(style.overflow, 'hidden'); }); - this.clock.tick(11); - assert.equal(this.liveRegion.getAttribute('role'), 'group'); - // Third: no role provided should revert to default status. - Blockly.utils.aria.announceDynamicAriaState('Back to normal'); - this.clock.tick(11); + test('createLiveRegion only creates one region (singleton)', function () { + // Calling again should not create a duplicate. + Blockly.utils.aria.initializeGlobalAriaLiveRegion( + this.workspace.getInjectionDiv(), + ); - assert.equal(this.liveRegion.getAttribute('role'), 'status'); + const regions = this.workspace + .getInjectionDiv() + .querySelectorAll('#blocklyAriaAnnounce'); + + assert.equal(regions.length, 1); + }); + + test('announcement is delayed', function () { + Blockly.utils.aria.announceDynamicAriaState('Hello world'); + + assert.equal(this.liveRegion.textContent, ''); + + // Advance past the delay in announceDynamicAriaState. + this.clock.tick(11); + assert.include(this.liveRegion.textContent, 'Hello world'); + }); + + test('repeated announcements are unique', function () { + Blockly.utils.aria.announceDynamicAriaState('Block moved'); + this.clock.tick(11); + + const first = this.liveRegion.textContent; + + Blockly.utils.aria.announceDynamicAriaState('Block moved'); + this.clock.tick(11); + + const second = this.liveRegion.textContent; + + assert.notEqual(first, second); + }); + + test('last write wins when called rapidly', function () { + Blockly.utils.aria.announceDynamicAriaState('First message'); + Blockly.utils.aria.announceDynamicAriaState('Second message'); + Blockly.utils.aria.announceDynamicAriaState('Final message'); + + this.clock.tick(11); + + assert.include(this.liveRegion.textContent, 'Final message'); + }); + + test('assertive option sets aria-live assertive', function () { + Blockly.utils.aria.announceDynamicAriaState('Warning', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE, + role: null, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('aria-live'), 'assertive'); + }); + + test('role option updates role attribute', function () { + Blockly.utils.aria.announceDynamicAriaState('Alert message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + }); + + test('role and text update after delay', function () { + // Initial announcement to establish baseline role + text. + Blockly.utils.aria.announceDynamicAriaState('Initial message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.STATUS, + }); + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + const initialText = this.liveRegion.textContent; + + // Now announce with different role. + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + // Before delay: role and text should not have changed yet. + this.clock.tick(5); + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + assert.equal(this.liveRegion.textContent, initialText); + + // After delay: both should update. + this.clock.tick(6); + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + assert.include(this.liveRegion.textContent, 'Group message'); + }); + test('missing role does not clear default status role', function () { + Blockly.utils.aria.announceDynamicAriaState('Hello world'); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); + test('custom role overrides default status role', function () { + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + }); + test('role reverts to status after custom role when role not provided', function () { + // First: default + Blockly.utils.aria.announceDynamicAriaState('Normal message'); + this.clock.tick(11); + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + + // Second: custom role + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + this.clock.tick(11); + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + + // Third: no role provided should revert to default status. + Blockly.utils.aria.announceDynamicAriaState('Back to normal'); + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); + }); + suite('Utils', function () { + let element; + + setup(function () { + element = document.createElement('div'); + document.body.appendChild(element); + }); + + teardown(function () { + element.remove(); + }); + + test('getRole returns null for element with no role', function () { + assert.isNull(Blockly.utils.aria.getRole(element)); + }); + + test('getRole returns correct role if set', function () { + element.setAttribute('role', 'button'); + assert.equal( + Blockly.utils.aria.getRole(element), + Blockly.utils.aria.Role.BUTTON, + ); + }); + + test('getRole returns null for unknown role', function () { + element.setAttribute('role', 'foobar'); + assert.isNull(Blockly.utils.aria.getRole(element)); + }); + + test('setState sets aria state as attribute', function () { + Blockly.utils.aria.setState( + element, + Blockly.utils.aria.State.DISABLED, + true, + ); + assert.equal(element.getAttribute('aria-disabled'), 'true'); + }); + + test('getState retrieves previously set state', function () { + Blockly.utils.aria.setState( + element, + Blockly.utils.aria.State.HIDDEN, + false, + ); + assert.equal( + Blockly.utils.aria.getState(element, Blockly.utils.aria.State.HIDDEN), + 'false', + ); + }); + + test('getState returns null for state not set', function () { + assert.isNull( + Blockly.utils.aria.getState(element, Blockly.utils.aria.State.SELECTED), + ); + }); + + test('clearState removes previously set attribute', function () { + Blockly.utils.aria.setState( + element, + Blockly.utils.aria.State.CHECKED, + true, + ); + assert.equal(element.getAttribute('aria-checked'), 'true'); + + Blockly.utils.aria.clearState(element, Blockly.utils.aria.State.CHECKED); + assert.isNull(element.getAttribute('aria-checked')); + }); + + test('setState handles array values correctly', function () { + Blockly.utils.aria.setState(element, Blockly.utils.aria.State.LABEL, [ + 'one', + 'two', + 'three', + ]); + assert.equal(element.getAttribute('aria-label'), 'one two three'); + }); }); }); From 5bc04b64357fbda18f596e3e7b37863ab3834acf Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:12:00 -0400 Subject: [PATCH 035/200] chore: add example to JSDoc for getAriaValue (#9692) --- packages/blockly/core/field.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index 2ecef1681..9f6792ebb 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -329,6 +329,11 @@ export abstract class Field * entirely when there may be a better contextual placeholder to use, instead, * specific to the field. * + * For example, a text input field may have a value of null when empty. To + * avoid hiding this field from screen reader, implementations should ensure + * that if the value is null, this function would return an appropriate, + * localized value such as "empty text". + * * Implementations are responsible for, and encouraged to, return a localized * version of the ARIA representation of the field's value. * From 41319baf5d8ec4714858f70b314ec62a934d4f93 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 8 Apr 2026 13:14:04 -0700 Subject: [PATCH 036/200] feat: Add keyboard shortcut to perform an action on the currently focused element (#9673) * feat: Add keyboard shortcut to perform an action on the currently focused element * test: Add tests * chore: Add example of shortcut formats * fix: Don't show toast if shortcut doesn't exist * chore: Clarify use of Zelos in tests * fix: Skip help hint toast until shortcut is added --- packages/blockly/core/block_svg.ts | 30 +++ .../core/bubbles/mini_workspace_bubble.ts | 9 + .../blockly/core/bubbles/textinput_bubble.ts | 8 + .../comments/rendered_workspace_comment.ts | 9 + packages/blockly/core/field.ts | 8 + packages/blockly/core/flyout_button.ts | 13 + packages/blockly/core/hints.ts | 40 +++ packages/blockly/core/icons/icon.ts | 18 ++ .../core/interfaces/i_focusable_node.ts | 7 + .../core/keyboard_nav/keyboard_mover.ts | 2 +- packages/blockly/core/shortcut_items.ts | 25 ++ .../blockly/core/utils/shortcut_formatting.ts | 138 +++++++++++ packages/blockly/core/utils/useragent.ts | 2 + packages/blockly/core/workspace_svg.ts | 9 + packages/blockly/msg/json/en.json | 4 +- packages/blockly/msg/json/qqq.json | 4 +- packages/blockly/msg/messages.js | 6 + .../tests/mocha/shortcut_items_test.js | 232 ++++++++++++++++-- 18 files changed, 544 insertions(+), 20 deletions(-) create mode 100644 packages/blockly/core/utils/shortcut_formatting.ts diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 051d7ace5..cf6952a85 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -35,6 +35,7 @@ import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import {FieldLabel} from './field_label.js'; import {getFocusManager} from './focus_manager.js'; +import * as hints from './hints.js'; import {IconType} from './icons/icon_types.js'; import {MutatorIcon} from './icons/mutator_icon.js'; import {WarningIcon} from './icons/warning_icon.js'; @@ -52,6 +53,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {IIcon} from './interfaces/i_icon.js'; import * as internalConstants from './internal_constants.js'; +import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; import {RenderedConnection} from './rendered_connection.js'; @@ -1903,6 +1905,34 @@ export class BlockSvg return true; } + /** + * Handles the user acting on this block via keyboard navigation. + * If this block is in the flyout, a new copy is spawned in move mode on the + * main workspace. If this block has a single full-block field, that field + * will be focused. Otherwise, this is a no-op. + */ + performAction() { + if (this.workspace.isFlyout) { + KeyboardMover.mover.startMove(this); + return; + } else if (this.isSimpleReporter()) { + for (const input of this.inputList) { + for (const field of input.fieldRow) { + if (field.isClickable() && field.isFullBlockField()) { + field.showEditor(); + return; + } + } + } + } + + if (this.workspace.getNavigator().getFirstChild(this)) { + hints.showBlockNavigationHint(this.workspace); + } else { + hints.showHelpHint(this.workspace); + } + } + /** * Returns a set of all of the parent blocks of the given block. * diff --git a/packages/blockly/core/bubbles/mini_workspace_bubble.ts b/packages/blockly/core/bubbles/mini_workspace_bubble.ts index 00a50dc30..956787de4 100644 --- a/packages/blockly/core/bubbles/mini_workspace_bubble.ts +++ b/packages/blockly/core/bubbles/mini_workspace_bubble.ts @@ -6,6 +6,7 @@ import type {BlocklyOptions} from '../blockly_options.js'; import {Abstract as AbstractEvent} from '../events/events_abstract.js'; +import {getFocusManager} from '../focus_manager.js'; import {KeyboardMover} from '../keyboard_nav/keyboard_mover.js'; import {Options} from '../options.js'; import {Coordinate} from '../utils/coordinate.js'; @@ -287,4 +288,12 @@ export class MiniWorkspaceBubble extends Bubble { 'monkey-patched in by blockly.ts', ); } + + /** + * Handles the user acting on this bubble via keyboard navigation by focusing + * the mutator workspace. + */ + performAction() { + getFocusManager().focusTree(this.getWorkspace()); + } } diff --git a/packages/blockly/core/bubbles/textinput_bubble.ts b/packages/blockly/core/bubbles/textinput_bubble.ts index 0bad5fabc..3ede92b74 100644 --- a/packages/blockly/core/bubbles/textinput_bubble.ts +++ b/packages/blockly/core/bubbles/textinput_bubble.ts @@ -279,6 +279,14 @@ export class TextInputBubble extends Bubble { getEditor() { return this.editor; } + + /** + * Handles the user acting on this bubble via keyboard navigation by focusing + * the comment editor. + */ + performAction() { + getFocusManager().focusNode(this.getEditor()); + } } Css.register(` diff --git a/packages/blockly/core/comments/rendered_workspace_comment.ts b/packages/blockly/core/comments/rendered_workspace_comment.ts index b422d252d..5edafd5fe 100644 --- a/packages/blockly/core/comments/rendered_workspace_comment.ts +++ b/packages/blockly/core/comments/rendered_workspace_comment.ts @@ -358,4 +358,13 @@ export class RenderedWorkspaceComment canBeFocused(): boolean { return true; } + + /** + * Handles the user acting on this comment via keyboard navigation. + * Expands the comment and focuses its editor. + */ + performAction() { + this.setCollapsed(false); + getFocusManager().focusNode(this.getEditorFocusableNode()); + } } diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index 9f6792ebb..295a13863 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -1488,6 +1488,14 @@ export abstract class Field return true; } + /** + * Handles the user acting on this field via keyboard navigation. + * Shows and focuses the field editor. + */ + performAction() { + this.showEditor(); + } + /** * Subclasses should reimplement this method to construct their Field * subclass from a JSON arg object. diff --git a/packages/blockly/core/flyout_button.ts b/packages/blockly/core/flyout_button.ts index 1a61ae118..3396258c6 100644 --- a/packages/blockly/core/flyout_button.ts +++ b/packages/blockly/core/flyout_button.ts @@ -421,6 +421,19 @@ export class FlyoutButton getId() { return this.id; } + + /** + * Handles the user acting on this button via keyboard navigation. + * Invokes the click handler callback. + */ + performAction(): void { + if (!this.isFlyoutLabel) { + const callback = this.targetWorkspace.getButtonCallback(this.callbackKey); + if (callback) { + callback(this); + } + } + } } /** CSS for buttons and labels. See css.js for use. */ diff --git a/packages/blockly/core/hints.ts b/packages/blockly/core/hints.ts index f01d9287c..25b966183 100644 --- a/packages/blockly/core/hints.ts +++ b/packages/blockly/core/hints.ts @@ -6,11 +6,15 @@ import {Msg} from './msg.js'; import {Toast} from './toast.js'; +import {getShortActionShortcut} from './utils/shortcut_formatting.js'; import * as userAgent from './utils/useragent.js'; import type {WorkspaceSvg} from './workspace_svg.js'; const unconstrainedMoveHintId = 'unconstrainedMoveHint'; const constrainedMoveHintId = 'constrainedMoveHint'; +const helpHintId = 'helpHint'; +const blockNavigationHintId = 'blockNavigationHint'; +const workspaceNavigationHintId = 'workspaceNavigationHint'; /** * Nudge the user to use unconstrained movement. @@ -62,3 +66,39 @@ export function clearMoveHints(workspace: WorkspaceSvg) { Toast.hide(workspace, constrainedMoveHintId); Toast.hide(workspace, unconstrainedMoveHintId); } + +/** + * Nudge the user to open the help. + * + * @param workspace The workspace. + */ +export function showHelpHint(workspace: WorkspaceSvg) { + const shortcut = getShortActionShortcut('list_shortcuts'); + if (!shortcut) return; + + const message = Msg['HELP_PROMPT'].replace('%1', shortcut); + const id = helpHintId; + Toast.show(workspace, {message, id}); +} + +/** + * Tell the user how to navigate inside blocks. + * + * @param workspace The workspace. + */ +export function showBlockNavigationHint(workspace: WorkspaceSvg) { + const message = Msg['KEYBOARD_NAV_BLOCK_NAVIGATION_HINT']; + const id = blockNavigationHintId; + Toast.show(workspace, {message, id}); +} + +/** + * Tell the user how to navigate inside the workspace. + * + * @param workspace The workspace. + */ +export function showWorkspaceNavigationHint(workspace: WorkspaceSvg) { + const message = Msg['KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT']; + const id = workspaceNavigationHintId; + Toast.show(workspace, {message, id}); +} diff --git a/packages/blockly/core/icons/icon.ts b/packages/blockly/core/icons/icon.ts index c8cfffaa4..30d11543c 100644 --- a/packages/blockly/core/icons/icon.ts +++ b/packages/blockly/core/icons/icon.ts @@ -7,10 +7,12 @@ import type {Block} from '../block.js'; import type {BlockSvg} from '../block_svg.js'; import * as browserEvents from '../browser_events.js'; +import {getFocusManager} from '../focus_manager.js'; import type {IContextMenu} from '../interfaces/i_contextmenu.js'; 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 renderManagement from '../render_management.js'; import * as tooltip from '../tooltip.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; @@ -189,6 +191,22 @@ export abstract class Icon implements IIcon, IContextMenu { return true; } + /** + * Handles the user acting on this icon via keyboard navigation. + * Performs the same action as a click would, and focuses this icon's bubble + * if it has one. + */ + performAction() { + this.onClick(); + renderManagement.finishQueuedRenders().then(() => { + if (hasBubble(this) && this.bubbleIsVisible()) { + const bubble = this.getBubble(); + if (!bubble) return; + getFocusManager().focusNode(bubble); + } + }); + } + /** * Returns the block that this icon is attached to. * diff --git a/packages/blockly/core/interfaces/i_focusable_node.ts b/packages/blockly/core/interfaces/i_focusable_node.ts index 57ec1a126..37dd08bc4 100644 --- a/packages/blockly/core/interfaces/i_focusable_node.ts +++ b/packages/blockly/core/interfaces/i_focusable_node.ts @@ -99,6 +99,13 @@ export interface IFocusableNode { * @returns Whether this node can be focused by FocusManager. */ canBeFocused(): boolean; + + /** + * Optional method invoked when this node has focus and the user acts on it by + * pressing Enter or Space. Behavior should generally be similar to the node + * being clicked on. + */ + performAction?(): void; } /** diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index ea2aefc36..60743125b 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -102,7 +102,7 @@ export class KeyboardMover { * @param event The keyboard event that triggered this move. * @returns True iff a move has successfully begun. */ - startMove(draggable: IDraggable, event: KeyboardEvent) { + startMove(draggable: IDraggable, event?: KeyboardEvent) { if (!this.canMove(draggable) || this.isMoving()) return false; const DraggerClass = registry.getClassFromOptions( diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index f4c3dcea5..5e3cea346 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -59,6 +59,7 @@ export enum names { NEXT_STACK = 'next_stack', PREVIOUS_STACK = 'previous_stack', INFORMATION = 'information', + PERFORM_ACTION = 'perform_action', } /** @@ -846,6 +847,29 @@ export function registerStackNavigation() { ShortcutRegistry.registry.register(previousStackShortcut); } +/** + * Registers keyboard shortcut to perform an action on the focused element. + */ +export function registerPerformAction() { + const performActionShortcut: KeyboardShortcut = { + name: names.PERFORM_ACTION, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (_workspace, e) => { + keyboardNavigationController.setIsActive(true); + const focusedNode = getFocusManager().getFocusedNode(); + if (focusedNode && 'performAction' in focusedNode) { + e.preventDefault(); + focusedNode.performAction?.(); + return true; + } + return false; + }, + keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE], + allowCollision: true, + }; + ShortcutRegistry.registry.register(performActionShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -874,6 +898,7 @@ export function registerKeyboardNavigationShortcuts() { registerArrowNavigation(); registerDisconnectBlock(); registerStackNavigation(); + registerPerformAction(); } /** diff --git a/packages/blockly/core/utils/shortcut_formatting.ts b/packages/blockly/core/utils/shortcut_formatting.ts new file mode 100644 index 000000000..1009777c0 --- /dev/null +++ b/packages/blockly/core/utils/shortcut_formatting.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Msg} from '../msg.js'; +import {ShortcutRegistry} from '../shortcut_registry.js'; +import * as userAgent from './useragent.js'; + +/** + * Find the primary shortcut for this platform and return it as single string + * in a short user facing format. + * + * @internal + * @param action The action name, e.g. "cut". + * @returns The formatted shortcut. + */ +export function getShortActionShortcut(action: string): string { + const shortcuts = getActionShortcutsAsKeys(action, shortModifierNames); + if (shortcuts.length) { + const parts = shortcuts[0]; + return parts.join(userAgent.APPLE ? ' ' : ' + '); + } + + return ''; +} + +/** + * Find the relevant shortcuts for the given action for the current platform. + * Keys are returned in a long user facing format, e.g. "Command ⌘ Option ⌥ C" + * + * @internal + * @param action The action name, e.g. "cut". + * @returns The formatted shortcuts as individual keys. + */ +export function getLongActionShortcutsAsKeys(action: string): string[][] { + return getActionShortcutsAsKeys(action, longModifierNames); +} + +const longModifierNames: Record = { + 'Control': Msg['CONTROL_KEY'], + 'Meta': Msg['COMMAND_KEY'], + 'Alt': userAgent.APPLE ? Msg['OPTION_KEY'] : Msg['ALT_KEY'], +}; + +const shortModifierNames: Record = { + 'Control': Msg['CONTROL_KEY'], + 'Meta': '⌘', + 'Alt': userAgent.APPLE ? '⌥' : Msg['ALT_KEY'], +}; + +/** + * Find the relevant shortcuts for the given action for the current platform. + * Keys are returned in a short user facing format, e.g. "⌘ ⌥ C" + * + * This could be considerably simpler if we only bound shortcuts relevant to the + * current platform or tagged them with a platform. + * + * @param action The action name, e.g. "cut". + * @param modifierNames The names to use for the Meta/Control/Alt modifiers. + * @returns The formatted shortcuts. + */ +function getActionShortcutsAsKeys( + action: string, + modifierNames: Record, +): string[][] { + const shortcuts = ShortcutRegistry.registry.getKeyCodesByShortcutName(action); + if (shortcuts.length === 0) { + return []; + } + // See ShortcutRegistry.createSerializedKey for the starting format. + const shortcutsAsParts = shortcuts.map((shortcut) => shortcut.split('+')); + // Prefer e.g. Cmd+Shift to Shift+Cmd. + shortcutsAsParts.forEach((s) => + s.sort((a, b) => { + const aValue = modifierOrder(a); + const bValue = modifierOrder(b); + return aValue - bValue; + }), + ); + + // Needed to prefer Command to Option where we've bound Alt. + shortcutsAsParts.sort((a, b) => { + const aValue = a.includes('Meta') ? 1 : 0; + const bValue = b.includes('Meta') ? 1 : 0; + return bValue - aValue; + }); + let currentPlatform = shortcutsAsParts.filter((shortcut) => { + const isMacShortcut = shortcut.includes('Meta'); + return isMacShortcut === userAgent.APPLE; + }); + currentPlatform = + currentPlatform.length === 0 ? shortcutsAsParts : currentPlatform; + + // Prefer simpler shortcuts. This promotes Ctrl+Y for redo. + currentPlatform.sort((a, b) => { + return a.length - b.length; + }); + // If there are modifiers return only one shortcut on the assumption they are + // intended for different platforms. Otherwise assume they are alternatives. + const hasModifiers = currentPlatform.some((shortcut) => + shortcut.some( + (key) => 'Meta' === key || 'Alt' === key || 'Control' === key, + ), + ); + const chosen = hasModifiers ? [currentPlatform[0]] : currentPlatform; + return chosen.map((shortcut) => { + return shortcut + .map((maybeNumeric) => + Number.isFinite(+maybeNumeric) + ? String.fromCharCode(+maybeNumeric) + : maybeNumeric, + ) + .map((k) => upperCaseFirst(modifierNames[k] ?? k)); + }); +} + +/** + * Convert the first character to uppercase. + * + * @param str String. + * @returns The string in title case. + */ +function upperCaseFirst(str: string) { + return str.charAt(0).toUpperCase() + str.substring(1); +} + +/** + * Preferred listing order of untranslated modifiers. + */ +const modifierOrdering: string[] = ['Meta', 'Control', 'Alt', 'Shift']; + +function modifierOrder(key: string): number { + const order = modifierOrdering.indexOf(key); + // Regular keys at the end. + return order === -1 ? Number.MAX_VALUE : order; +} diff --git a/packages/blockly/core/utils/useragent.ts b/packages/blockly/core/utils/useragent.ts index 92180c21d..0473375b1 100644 --- a/packages/blockly/core/utils/useragent.ts +++ b/packages/blockly/core/utils/useragent.ts @@ -84,3 +84,5 @@ export const IPHONE: boolean = isIPhone; export const MAC: boolean = isMac; export const MOBILE: boolean = isMobile; + +export const APPLE: boolean = MAC || IPAD || IPHONE; diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index e36668ced..6b548647a 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -45,6 +45,7 @@ import type {FlyoutButton} from './flyout_button.js'; import {getFocusManager} from './focus_manager.js'; import {Gesture} from './gesture.js'; import {Grid} from './grid.js'; +import * as hints from './hints.js'; import {MutatorIcon} from './icons/mutator_icon.js'; import {isAutoHideable} from './interfaces/i_autohideable.js'; import type {IBoundedElement} from './interfaces/i_bounded_element.js'; @@ -2852,6 +2853,14 @@ export class WorkspaceSvg } } + /** + * Handles the user acting on this workspace via keyboard navigation by + * prompting them to use the arrow keys (instead of Enter) to navigate. + */ + performAction() { + hints.showWorkspaceNavigationHint(this); + } + /** * Returns an object responsible for coordinating movement of focus between * items on this workspace in response to keyboard navigation commands. diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 1701adcfc..0716475dc 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -425,5 +425,7 @@ "WORKSPACE_CONTENTS_BLOCKS_ONE": "One stack of blocks%2 in workspace.", "WORKSPACE_CONTENTS_BLOCKS_ZERO": "No blocks%2 in workspace.", "WORKSPACE_CONTENTS_COMMENTS_MANY": " and %1 comments", - "WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment" + "WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment", + "KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Use the right arrow key to navigate inside of blocks", + "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index a8023b6e3..6392bd683 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -432,5 +432,7 @@ "WORKSPACE_CONTENTS_BLOCKS_ONE": "ARIA live region message announcing there is one stack of blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'One stack of blocks in workspace.'\n* 'One stack of blocks and 1 comment in workspace.'", "WORKSPACE_CONTENTS_BLOCKS_ZERO": "ARIA live region message announcing there are no blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'No blocks in workspace.'\n* 'No blocks and 3 comments in workspace.'", "WORKSPACE_CONTENTS_COMMENTS_MANY": "ARIA live region phrase appended when there are multiple workspace comments. \n\nParameters:\n* %1 - the number of comments (integer greater than 1)", - "WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment." + "WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment.", + "KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Message shown when a user presses Enter with a navigable block focused.", + "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index bc6316cfd..bf36a43c4 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1718,3 +1718,9 @@ Blockly.Msg.WORKSPACE_CONTENTS_COMMENTS_MANY = ' and %1 comments'; /** @type {string} */ /// ARIA live region phrase appended when there is exactly one workspace comment. Blockly.Msg.WORKSPACE_CONTENTS_COMMENTS_ONE = ' and one comment'; +/** @type {string} */ +/// Message shown when a user presses Enter with a navigable block focused. +Blockly.Msg.KEYBOARD_NAV_BLOCK_NAVIGATION_HINT = 'Use the right arrow key to navigate inside of blocks'; +/** @type {string} */ +/// Message shown when a user presses Enter with the workspace focused. +Blockly.Msg.KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT = 'Use the arrow keys to navigate'; diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index e8931e8fa..f9c7fe3f5 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -20,7 +20,8 @@ suite('Keyboard Shortcut Items', function () { setup(function () { sharedTestSetup.call(this); const toolbox = document.getElementById('toolbox-test'); - this.workspace = Blockly.inject('blocklyDiv', {toolbox}); + // Zelos has full-block fields, which we want to exercise in tests. + this.workspace = Blockly.inject('blocklyDiv', {toolbox, renderer: 'zelos'}); this.injectionDiv = this.workspace.getInjectionDiv(); Blockly.ContextMenuRegistry.registry.reset(); Blockly.ContextMenuItems.registerDefaultOptions(); @@ -633,22 +634,6 @@ suite('Keyboard Shortcut Items', function () { }); suite('Focus Toolbox (T)', function () { - setup(function () { - Blockly.defineBlocksWithJsonArray([ - { - 'type': 'basic_block', - 'message0': '%1', - 'args0': [ - { - 'type': 'field_input', - 'name': 'TEXT', - 'text': 'default', - }, - ], - }, - ]); - }); - test('Does not change focus when toolbox item is already focused', function () { const item = this.workspace.getToolbox().getToolboxItems()[1]; Blockly.getFocusManager().focusNode(item); @@ -1033,4 +1018,217 @@ suite('Keyboard Shortcut Items', function () { ); }); }); + + suite('Perform Action (Enter)', function () { + test('Shows a toast with navigation hints on the workspace', function () { + const toastSpy = sinon.spy(Blockly.Toast, 'show'); + + Blockly.getFocusManager().focusNode(this.workspace); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + sinon.assert.calledWith(toastSpy, this.workspace, { + id: 'workspaceNavigationHint', + message: Blockly.Msg['KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT'], + }); + + toastSpy.restore(); + }); + + test('Inserts blocks from the flyout in move mode', function () { + this.workspace.getToolbox().selectItemByPosition(0); + const block = this.workspace + .getNavigator() + .getFirstChild(this.workspace.getFlyout().getWorkspace()); + assert.instanceOf(block, Blockly.BlockSvg); + Blockly.getFocusManager().focusNode(block); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + const movingBlock = Blockly.getFocusManager().getFocusedNode(); + assert.notEqual(block, movingBlock); + assert.instanceOf(movingBlock, Blockly.BlockSvg); + assert.isTrue(movingBlock.isDragging()); + assert.isFalse(movingBlock.workspace.isFlyout); + + Blockly.KeyboardMover.mover.abortMove(); + }); + + test('Shows a toast with navigation hints for navigable blocks', function () { + const toastSpy = sinon.spy(Blockly.Toast, 'show'); + + const block = this.workspace.newBlock('controls_if'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + sinon.assert.calledWith(toastSpy, this.workspace, { + id: 'blockNavigationHint', + message: Blockly.Msg['KEYBOARD_NAV_BLOCK_NAVIGATION_HINT'], + }); + toastSpy.restore(); + }); + + // Reenable this tests once the shortcut listing shortcut has been added. + test.skip('Shows a toast with instructions to view help for non-navigable blocks', function () { + const toastSpy = sinon.spy(Blockly.Toast, 'show'); + + const block = this.workspace.newBlock('test_align_dummy_right'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + sinon.assert.calledWith(toastSpy, this.workspace, { + id: 'helpHint', + message: Blockly.Msg['HELP_PROMPT'].replace('%1', ''), + }); + toastSpy.restore(); + }); + + test('Focuses field editor for blocks with full-block fields', function () { + const block = this.workspace.newBlock('math_number'); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + const field = block.getField('NUM'); + assert.isTrue(Blockly.WidgetDiv.isVisible()); + assert.isTrue(field.isBeingEdited_); + }); + + test('Focuses field editor for fields', function () { + const block = this.workspace.newBlock('logic_compare'); + block.initSvg(); + block.render(); + const field = block.getField('OP'); + Blockly.getFocusManager().focusNode(field); + + assert.isFalse(Blockly.DropDownDiv.isVisible()); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + assert.isTrue(Blockly.DropDownDiv.isVisible()); + }); + + test('Expands and focuses workspace comment editors', function () { + const comment = this.workspace.newComment(); + comment.setCollapsed(true); + Blockly.getFocusManager().focusNode(comment); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + comment.getEditorFocusableNode(), + ); + assert.isFalse(comment.view.isCollapsed()); + }); + + test('Focuses mutator workspace for mutator bubble', async function () { + const block = this.workspace.newBlock('controls_if'); + block.initSvg(); + block.render(); + const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + Blockly.getFocusManager().focusNode(icon.getBubble()); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedTree(), + icon.getWorkspace(), + ); + }); + + test('Focuses comment editor for block comment bubble', async function () { + const block = this.workspace.newBlock('controls_if'); + block.initSvg(); + block.render(); + block.setCommentText('Hello'); + const icon = block.getIcon(Blockly.icons.CommentIcon.TYPE); + await icon.setBubbleVisible(true); + Blockly.getFocusManager().focusNode(icon.getBubble()); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + icon.getBubble().getEditor(), + ); + }); + + test('Focuses bubble for icons', async function () { + const block = this.workspace.newBlock('controls_if'); + block.initSvg(); + block.render(); + + block.setCommentText('Hello world'); + block.setWarningText('Danger!'); + + const iconTypes = [ + Blockly.icons.CommentIcon.TYPE, + Blockly.icons.WarningIcon.TYPE, + Blockly.icons.MutatorIcon.TYPE, + ]; + + for (const iconType of iconTypes) { + const icon = block.getIcon(iconType); + Blockly.getFocusManager().focusNode(icon); + + const bubbleShown = new Promise((resolve) => { + this.workspace.addChangeListener((event) => { + if (event.type === Blockly.Events.BUBBLE_OPEN) { + resolve(); + } + }); + }); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + this.clock.tick(100); + + await bubbleShown; + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + icon.getBubble(), + ); + } + }); + + test('Triggers flyout button actions', function () { + const toolbox = this.workspace.getToolbox(); + toolbox.selectItemByPosition(3); + const button = this.workspace.getFlyout().getContents()[0].getElement(); + assert.instanceOf(button, Blockly.FlyoutButton); + Blockly.getFocusManager().focusNode(button); + + const oldCallback = this.workspace.getButtonCallback('CREATE_VARIABLE'); + let called = false; + this.workspace.registerButtonCallback('CREATE_VARIABLE', () => { + called = true; + }); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + assert.isTrue(called); + this.workspace.registerButtonCallback('CREATE_VARIABLE', oldCallback); + }); + }); }); From c7fd3f339e1ad594c8c9a65e9d23e2ff3d341927 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 8 Apr 2026 13:29:03 -0700 Subject: [PATCH 037/200] release: Bump version to 13.0.0-beta.0 (#9693) --- package-lock.json | 6 +++--- packages/blockly/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0a4f05b47..e0d77d384 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockly-repo", - "version": "0.0.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockly-repo", - "version": "0.0.0", + "version": "0.0.1", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -1950,7 +1950,7 @@ } }, "packages/blockly": { - "version": "12.5.1", + "version": "13.0.0-beta.0", "hasInstallScript": true, "license": "Apache-2.0", "devDependencies": { diff --git a/packages/blockly/package.json b/packages/blockly/package.json index 7f6d53970..e6f9fe86c 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -1,6 +1,6 @@ { "name": "blockly", - "version": "12.5.1", + "version": "13.0.0-beta.0", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" From 229450a1b7fcb3d309d68be502d9442e9376131c Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:37:51 -0400 Subject: [PATCH 038/200] chore: remove unused statuses (#9698) * chore: remove unused statuses * fix: revert removal of valuemax and valuemin --- packages/blockly/core/utils/aria.ts | 62 +---------------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 5837adead..0d3dfd7cc 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -123,19 +123,13 @@ export enum State { * * Value: one of {true, false}. */ - ATOMIC = 'ATOMIC', + ATOMIC = 'atomic', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-checked. * * Value: one of {true, false, mixed, undefined}. */ CHECKED = 'checked', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-colcount. - * - * Value: an integer representing the number of columns in a grid. - */ - COLCOUNT = 'colcount', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-controls. * @@ -196,72 +190,18 @@ export enum State { * Value: one of {polite, assertive, off}. */ LIVE = 'live', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-orientation. - * - * Value: one of {horizontal, vertical, undefined}. - */ - ORIENTATION = 'orientation', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-posinset. - * - * Value: an integer representing the position of the element within a set of related elements. - */ - POSINSET = 'posinset', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed. - * - * Value: one of {true, false, mixed, undefined}. - */ - PRESSED = 'pressed', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-readonly. - * - * Value: one of {true, false}. - */ - READONLY = 'readonly', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-required. - * - * Value: one of {true, false}. - */ - REQUIRED = 'required', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-roledescription. * * Value: a string. */ ROLEDESCRIPTION = 'roledescription', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowcount. - * - * Value: an integer representing the number of rows in a grid or table. - */ - ROWCOUNT = 'rowcount', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowindex. - * - * Value: an integer representing the index of the element within a set of related elements. - */ - ROWINDEX = 'rowindex', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowspan. - * - * Value: an integer representing the number of rows a cell spans in a grid or table. - */ - ROWSPAN = 'rowspan', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-selected. * * Value:one of {true, false, undefined}. */ SELECTED = 'selected', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-setsize. - * - * Value: an integer representing the total number of elements in a set of related elements. - */ - SETSIZE = 'setsize', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-valuemax. * From 956f049dbd9956e54435ebfd11021ef6670e5a4b Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:52:21 -0400 Subject: [PATCH 039/200] Revert "chore: remove unused statuses (#9698)" (#9700) This reverts commit 229450a1b7fcb3d309d68be502d9442e9376131c. --- packages/blockly/core/utils/aria.ts | 62 ++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 0d3dfd7cc..5837adead 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -123,13 +123,19 @@ export enum State { * * Value: one of {true, false}. */ - ATOMIC = 'atomic', + ATOMIC = 'ATOMIC', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-checked. * * Value: one of {true, false, mixed, undefined}. */ CHECKED = 'checked', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-colcount. + * + * Value: an integer representing the number of columns in a grid. + */ + COLCOUNT = 'colcount', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-controls. * @@ -190,18 +196,72 @@ export enum State { * Value: one of {polite, assertive, off}. */ LIVE = 'live', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-orientation. + * + * Value: one of {horizontal, vertical, undefined}. + */ + ORIENTATION = 'orientation', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-posinset. + * + * Value: an integer representing the position of the element within a set of related elements. + */ + POSINSET = 'posinset', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed. + * + * Value: one of {true, false, mixed, undefined}. + */ + PRESSED = 'pressed', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-readonly. + * + * Value: one of {true, false}. + */ + READONLY = 'readonly', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-required. + * + * Value: one of {true, false}. + */ + REQUIRED = 'required', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-roledescription. * * Value: a string. */ ROLEDESCRIPTION = 'roledescription', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowcount. + * + * Value: an integer representing the number of rows in a grid or table. + */ + ROWCOUNT = 'rowcount', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowindex. + * + * Value: an integer representing the index of the element within a set of related elements. + */ + ROWINDEX = 'rowindex', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowspan. + * + * Value: an integer representing the number of rows a cell spans in a grid or table. + */ + ROWSPAN = 'rowspan', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-selected. * * Value:one of {true, false, undefined}. */ SELECTED = 'selected', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-setsize. + * + * Value: an integer representing the total number of elements in a set of related elements. + */ + SETSIZE = 'setsize', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-valuemax. * From a61222f7279096d98c33bb2b5bb6830207710df0 Mon Sep 17 00:00:00 2001 From: lizschwab Date: Fri, 10 Apr 2026 14:40:42 -0700 Subject: [PATCH 040/200] fix: Add default aria role to svg and group elements (#9697) * fix: Add default aria role to svg and group elements * addressed pr feedback --- packages/blockly/core/utils/dom.ts | 14 ++++++- packages/blockly/tests/mocha/utils_test.js | 45 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/utils/dom.ts b/packages/blockly/core/utils/dom.ts index 408798415..37bccb578 100644 --- a/packages/blockly/core/utils/dom.ts +++ b/packages/blockly/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. @@ -56,6 +57,17 @@ export function createSvgElement( opt_parent?: Element | null, ): T { const e = document.createElementNS(SVG_NS, `${name}`) as T; + /** + * For svg and group (g) elements, we set the role to generic so that they are ignored by assistive technologies. + */ + if ( + name === Svg.SVG.toString() || + name === Svg.G.toString() || + e.tagName === Svg.SVG.toString() || + e.tagName === Svg.G.toString() + ) { + aria.setRole(e, aria.Role.GENERIC); + } for (const key in attrs) { e.setAttribute(key, `${attrs[key]}`); } diff --git a/packages/blockly/tests/mocha/utils_test.js b/packages/blockly/tests/mocha/utils_test.js index accf164b7..da6eac7ce 100644 --- a/packages/blockly/tests/mocha/utils_test.js +++ b/packages/blockly/tests/mocha/utils_test.js @@ -433,6 +433,51 @@ suite('Utils', function () { Blockly.utils.dom.removeClass(p, 'zero'); assert.equal(p.className, '', 'Removing "zero"'); }); + + suite('createSvgElement', function () { + test('svg elements of type g have the generic role by default', function () { + const svgG = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.G, + {}, + ); + const g = Blockly.utils.dom.createSvgElement('g', {}); + assert.equal(svgG.getAttribute('role'), 'generic'); + assert.equal(g.getAttribute('role'), 'generic'); + }); + test('svg elements of type svg have the generic role by default', function () { + const svgSvg = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.SVG, + {}, + ); + const svg = Blockly.utils.dom.createSvgElement('svg', {}); + assert.equal(svgSvg.getAttribute('role'), 'generic'); + assert.equal(svg.getAttribute('role'), 'generic'); + }); + test('svg elements of type g reflect the role passed in when created', function () { + const svgG = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.G, { + role: 'button', + }); + const g = Blockly.utils.dom.createSvgElement('g', {role: 'button'}); + assert.equal(svgG.getAttribute('role'), 'button'); + assert.equal(g.getAttribute('role'), 'button'); + }); + test('svg elements of type svg reflect the role passed in when created', function () { + const svgSvg = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.SVG, + {role: 'button'}, + ); + const svg = Blockly.utils.dom.createSvgElement('svg', {role: 'button'}); + assert.equal(svgSvg.getAttribute('role'), 'button'); + assert.equal(svg.getAttribute('role'), 'button'); + }); + test('other svg elements do not default to generic role', function () { + const textElement = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.TEXT, + {}, + ); + assert.equal(textElement.getAttribute('role'), null); + }); + }); }); suite('String', function () { From 1478b1f0931ae29a974bf8c999787193d3327f45 Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:51:15 -0400 Subject: [PATCH 041/200] chore!: remove unused statuses This reverts commit 956f049dbd9956e54435ebfd11021ef6670e5a4b. --- packages/blockly/core/utils/aria.ts | 62 +---------------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 5837adead..0d3dfd7cc 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -123,19 +123,13 @@ export enum State { * * Value: one of {true, false}. */ - ATOMIC = 'ATOMIC', + ATOMIC = 'atomic', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-checked. * * Value: one of {true, false, mixed, undefined}. */ CHECKED = 'checked', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-colcount. - * - * Value: an integer representing the number of columns in a grid. - */ - COLCOUNT = 'colcount', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-controls. * @@ -196,72 +190,18 @@ export enum State { * Value: one of {polite, assertive, off}. */ LIVE = 'live', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-orientation. - * - * Value: one of {horizontal, vertical, undefined}. - */ - ORIENTATION = 'orientation', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-posinset. - * - * Value: an integer representing the position of the element within a set of related elements. - */ - POSINSET = 'posinset', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed. - * - * Value: one of {true, false, mixed, undefined}. - */ - PRESSED = 'pressed', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-readonly. - * - * Value: one of {true, false}. - */ - READONLY = 'readonly', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-required. - * - * Value: one of {true, false}. - */ - REQUIRED = 'required', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-roledescription. * * Value: a string. */ ROLEDESCRIPTION = 'roledescription', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowcount. - * - * Value: an integer representing the number of rows in a grid or table. - */ - ROWCOUNT = 'rowcount', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowindex. - * - * Value: an integer representing the index of the element within a set of related elements. - */ - ROWINDEX = 'rowindex', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowspan. - * - * Value: an integer representing the number of rows a cell spans in a grid or table. - */ - ROWSPAN = 'rowspan', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-selected. * * Value:one of {true, false, undefined}. */ SELECTED = 'selected', - /** - * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-setsize. - * - * Value: an integer representing the total number of elements in a set of related elements. - */ - SETSIZE = 'setsize', /** * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-valuemax. * From 13459a2b53461f103bf8080f9f9b7f4f16701ad4 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 13 Apr 2026 13:24:32 -0700 Subject: [PATCH 042/200] fix: Fix positioning of move marker on blocks (#9722) * fix: Fix positioning of move marker on blocks. * refactor: Move block topline calculation out of loop --- .../core/keyboard_nav/keyboard_mover.ts | 71 +++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/blockly/core/keyboard_nav/keyboard_mover.ts b/packages/blockly/core/keyboard_nav/keyboard_mover.ts index 60743125b..ddf97829f 100644 --- a/packages/blockly/core/keyboard_nav/keyboard_mover.ts +++ b/packages/blockly/core/keyboard_nav/keyboard_mover.ts @@ -3,14 +3,16 @@ * Copyright 2026 Raspberry Pi Foundation * SPDX-License-Identifier: Apache-2.0 */ +import type {BlockSvg} from '../block_svg.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IDragger} from '../interfaces/i_dragger.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import * as registry from '../registry.js'; +import * as renderManagement from '../render_management.js'; import {ShortcutRegistry} from '../shortcut_registry.js'; import {Coordinate} from '../utils/coordinate.js'; import {KeyCodes} from '../utils/keycodes.js'; import {MoveIndicator} from './move_indicator.js'; - /** * Cardinal directions in which a move can proceed. */ @@ -263,12 +265,69 @@ export class KeyboardMover { * Repositions the move indicator to the corner of the item being moved. */ private repositionMoveIndicator() { - const bounds = this.draggable?.getBoundingRectangle(); - if (!bounds) return; + renderManagement.finishQueuedRenders().then(() => { + let bounds = this.draggable?.getBoundingRectangle(); + if ( + this.draggable && + 'getBoundingRectangleWithoutChildren' in this.draggable + ) { + bounds = this.positionForBlockMoveIndicator(this.draggable as BlockSvg); + } - this.moveIndicator?.moveTo( - this.draggable?.workspace.RTL ? bounds.left : bounds.right, - bounds.top, + if (!bounds) return; + + this.moveIndicator?.moveTo( + this.draggable?.workspace.RTL ? bounds.left : bounds.right, + bounds.top, + ); + }); + } + + /** + * Returns a bounding box used for positioning the move indicator on a block. + * Blocks require special treatment because `BlockSvg.getBoundingRectangle()` + * includes the bounds of nested and subsequent blocks. Since the move + * indicator is positioned at the top right corner of the bounds, this can + * result in it appearing to float in empty space when e.g. a small block has + * a much wider block nested inside a statement input. BlockSvg also provides + * `getBoundingRectangleWithoutChildren()`, which addresses that case, but is + * insufficient because in the case of nested *value* blocks in a row, the + * child blocks' bounds should contribute to the bounding box. + * + * @param block The block to retrieve an adjusted bounding box for. + * @returns A bounding box for the given block whose top-right corner + * corresponds to the maximum visual extent of the given block's row. + */ + private positionForBlockMoveIndicator(block: BlockSvg) { + const navigator = block.workspace.getNavigator(); + let rightmost: IFocusableNode = block; + let nextCandidate = null; + // Find the rightmost element on the same visual row as the starting block. + while ((nextCandidate = navigator.getInNode(rightmost))) { + rightmost = nextCandidate; + } + + // Get the parent block of the rightmost element in the case where it is + // e.g. a field. + let targetBlock = navigator.getSourceBlockFromNode(rightmost); + + // Work backwards from the rightmost block; deeply nested value blocks do + // not have the same y position as their parent because they are visually + // depicted as being inside of it. Keep working up the parent block + // hierarchy until one is found with the same y position as the starting + // block, meaning is is the rightmost top-level value block in the same row + // as the starting block. + const topline = block.getBoundingRectangleWithoutChildren().getOrigin().y; + while ( + targetBlock?.getBoundingRectangleWithoutChildren().getOrigin().y !== + topline + ) { + targetBlock = targetBlock?.getParent() ?? null; + } + + return ( + targetBlock?.getBoundingRectangleWithoutChildren() ?? + block.getBoundingRectangleWithoutChildren() ); } From 4734bf95f05b7f6458aeaeacc28ad8c5ac00c56e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 14 Apr 2026 10:25:32 -0700 Subject: [PATCH 043/200] feat: Add keyboard shortcut to clean up the workspace (#9728) * feat: Add a keyboard shortcut to clean up the workspace * test: Add tests --- packages/blockly/core/shortcut_items.ts | 21 ++++++++++ .../tests/mocha/shortcut_items_test.js | 39 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 5e3cea346..625444a73 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -60,6 +60,7 @@ export enum names { PREVIOUS_STACK = 'previous_stack', INFORMATION = 'information', PERFORM_ACTION = 'perform_action', + CLEANUP = 'cleanup', } /** @@ -870,6 +871,25 @@ export function registerPerformAction() { ShortcutRegistry.registry.register(performActionShortcut); } +/** + * Registers keyboard shortcut to clean up the workspace. + */ +export function registerCleanup() { + const cleanupShortcut: KeyboardShortcut = { + name: names.CLEANUP, + preconditionFn: (workspace) => + !workspace.isDragging() && !workspace.isReadOnly(), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + workspace.cleanUp(); + return true; + }, + keyCodes: [KeyCodes.C], + allowCollision: true, + }; + ShortcutRegistry.registry.register(cleanupShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -899,6 +919,7 @@ export function registerKeyboardNavigationShortcuts() { registerDisconnectBlock(); registerStackNavigation(); registerPerformAction(); + registerCleanup(); } /** diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index f9c7fe3f5..29996977e 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -1231,4 +1231,43 @@ suite('Keyboard Shortcut Items', function () { this.workspace.registerButtonCallback('CREATE_VARIABLE', oldCallback); }); }); + + suite('Clean up workspace (C)', function () { + test('Arranges all blocks in a vertical column', function () { + this.workspace.newBlock('controls_if'); + const block2 = this.workspace.newBlock('controls_if'); + block2.moveBy(300, 20); + const block3 = this.workspace.newBlock('controls_if'); + block3.moveBy(-75, -60); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C); + this.workspace.getInjectionDiv().dispatchEvent(event); + + for (const block of this.workspace.getTopBlocks()) { + assert.equal(block.relativeCoords.x, 0); + } + }); + + test('Does nothing on a readonly workspace', function () { + this.workspace.newBlock('controls_if'); + const block2 = this.workspace.newBlock('controls_if'); + block2.moveBy(300, 20); + const block3 = this.workspace.newBlock('controls_if'); + block3.moveBy(-75, -60); + + this.workspace.setIsReadOnly(true); + + const oldBounds = this.workspace + .getTopBlocks(true) + .map((b) => b.getBoundingRectangle()); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C); + this.workspace.getInjectionDiv().dispatchEvent(event); + + const newBounds = this.workspace + .getTopBlocks(true) + .map((b) => b.getBoundingRectangle()); + assert.deepEqual(oldBounds, newBounds); + }); + }); }); From 3b9ed58f7103eaba5895f2e463ffa7fa6f2e832b Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 14 Apr 2026 12:47:01 -0700 Subject: [PATCH 044/200] feat: Add keyboard shortcut for duplicating blocks and workspace comments (#9727) * feat: Add keyboard shortcut for duplicating blocks and workspace comments * test: Add tests * chore: Fix copypasta --- packages/blockly/core/shortcut_items.ts | 30 ++++++++++ .../tests/mocha/shortcut_items_test.js | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 625444a73..0c594d0f9 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -60,6 +60,7 @@ export enum names { PREVIOUS_STACK = 'previous_stack', INFORMATION = 'information', PERFORM_ACTION = 'perform_action', + DUPLICATE = 'duplicate', CLEANUP = 'cleanup', } @@ -871,6 +872,34 @@ export function registerPerformAction() { ShortcutRegistry.registry.register(performActionShortcut); } +/** + * Registers keyboard shortcut to duplicate a block or workspace comment. + */ +export function registerDuplicate() { + const duplicateShortcut: KeyboardShortcut = { + name: names.DUPLICATE, + preconditionFn: (workspace, scope) => { + const {focusedNode} = scope; + return ( + !workspace.isDragging() && + !workspace.isReadOnly() && + (focusedNode instanceof BlockSvg ? focusedNode.isDuplicatable() : true) + ); + }, + callback: (workspace, _e, _shortcut, scope) => { + keyboardNavigationController.setIsActive(true); + const copyable = isICopyable(scope.focusedNode) && scope.focusedNode; + if (!copyable) return false; + const data = copyable.toCopyData(); + if (!data) return false; + return !!clipboard.paste(data, workspace); + }, + keyCodes: [KeyCodes.D], + allowCollision: true, + }; + ShortcutRegistry.registry.register(duplicateShortcut); +} + /** * Registers keyboard shortcut to clean up the workspace. */ @@ -919,6 +948,7 @@ export function registerKeyboardNavigationShortcuts() { registerDisconnectBlock(); registerStackNavigation(); registerPerformAction(); + registerDuplicate(); registerCleanup(); } diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 29996977e..8df3e662f 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -1232,6 +1232,64 @@ suite('Keyboard Shortcut Items', function () { }); }); + suite('Duplicate (D)', function () { + test('Can duplicate blocks', function () { + const block = this.workspace.newBlock('controls_if'); + Blockly.getFocusManager().focusNode(block); + assert.equal(this.workspace.getTopBlocks().length, 1); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.D); + this.workspace.getInjectionDiv().dispatchEvent(event); + const topBlocks = this.workspace.getTopBlocks(true); + assert.equal(topBlocks.length, 2); + assert.notEqual(topBlocks[1], block); + assert.equal(topBlocks[1].type, block.type); + }); + + test('Can duplicate workspace comments', function () { + const comment = this.workspace.newComment(); + comment.setText('Hello'); + Blockly.getFocusManager().focusNode(comment); + assert.equal(this.workspace.getTopComments().length, 1); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.D); + this.workspace.getInjectionDiv().dispatchEvent(event); + const topComments = this.workspace.getTopComments(true); + assert.equal(topComments.length, 2); + assert.notEqual(topComments[1], comment); + assert.equal(topComments[1].getText(), comment.getText()); + }); + + test('Does not duplicate blocks on a readonly workspace', function () { + const block = this.workspace.newBlock('controls_if'); + this.workspace.setIsReadOnly(true); + Blockly.getFocusManager().focusNode(block); + assert.equal(this.workspace.getTopBlocks().length, 1); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.D); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.equal(this.workspace.getTopBlocks().length, 1); + }); + + test('Does not duplicate blocks that are not duplicatable', function () { + const block = this.workspace.newBlock('controls_if'); + this.workspace.options.maxBlocks = 1; + assert.isFalse(block.isDuplicatable()); + assert.equal(this.workspace.getTopBlocks().length, 1); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.D); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.equal(this.workspace.getTopBlocks().length, 1); + }); + + test('Does not duplicate workspace comments on a readonly workspace', function () { + const comment = this.workspace.newComment(); + comment.setText('Hello'); + this.workspace.setIsReadOnly(true); + Blockly.getFocusManager().focusNode(comment); + assert.equal(this.workspace.getTopComments().length, 1); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.D); + this.workspace.getInjectionDiv().dispatchEvent(event); + assert.equal(this.workspace.getTopComments().length, 1); + }); + }); + suite('Clean up workspace (C)', function () { test('Arranges all blocks in a vertical column', function () { this.workspace.newBlock('controls_if'); From dc2afe3527cf18cdeb7d69ed08e1685620ad4cde Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 15 Apr 2026 08:04:06 -0700 Subject: [PATCH 045/200] fix: Improve accuracy of block navigability detection (#9725) --- packages/blockly/core/block_svg.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index cf6952a85..0d61501b3 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -1926,7 +1926,7 @@ export class BlockSvg } } - if (this.workspace.getNavigator().getFirstChild(this)) { + if (this.workspace.getNavigator().getInNode(this)) { hints.showBlockNavigationHint(this.workspace); } else { hints.showHelpHint(this.workspace); From 91d02eee02768d2d78846dab8d214bb64394586f Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 15 Apr 2026 08:12:08 -0700 Subject: [PATCH 046/200] feat: Add basic support for generating ARIA labels and roles for blocks (#9696) * feat: Add basic support for generating ARIA labels and roles for blocks * test: Add tests * chore: Fix lint * chore: Revert tooling removal of authors * chore: Adjust casing of method name * chore: Tweak name of verbosity enum value * chore: Adjust name of shadow block label method * chore: Add trailing newline * chore: Fix method casing * feat: Add method to retrieve a block's ARIA label * fix: Fix TSDoc * chore: Adjust method casing --- packages/blockly/core/block_aria_composer.ts | 317 +++++++++++++++++++ packages/blockly/core/block_svg.ts | 26 ++ packages/blockly/core/utils/aria.ts | 9 + packages/blockly/msg/json/en.json | 15 +- packages/blockly/msg/json/qqq.json | 13 +- packages/blockly/msg/messages.js | 46 +++ packages/blockly/tests/mocha/aria_test.js | 191 ++++++++++- 7 files changed, 612 insertions(+), 5 deletions(-) create mode 100644 packages/blockly/core/block_aria_composer.ts diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts new file mode 100644 index 000000000..fa06c4894 --- /dev/null +++ b/packages/blockly/core/block_aria_composer.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from './block_svg.js'; +import {ConnectionType} from './connection_type.js'; +import type {Input} from './inputs/input.js'; +import {inputTypes} from './inputs/input_types.js'; +import { + ISelectableToolboxItem, + isSelectableToolboxItem, +} from './interfaces/i_selectable_toolbox_item.js'; +import {Msg} from './msg.js'; +import {Role, setRole, setState, State, Verbosity} from './utils/aria.js'; + +/** + * Returns an ARIA representation of the specified block. + * + * The returned label will contain a complete context of the block, including: + * - Whether it begins a block stack or statement input stack. + * - Its constituent editable and non-editable fields. + * - Properties, including: disabled, collapsed, replaceable (a shadow), etc. + * - Its parent toolbox category. + * - Whether it has inputs. + * + * Beyond this, the returned label is specifically assembled with commas in + * select locations with the intention of better 'prosody' in the screen reader + * readouts since there's a lot of information being shared with the user. The + * returned label also places more important information earlier in the label so + * that the user gets the most important context as soon as possible in case + * they wish to stop readout early. + * + * The returned label will be specialized based on whether the block is part of a + * flyout. + * + * @internal + * @param block The block for which an ARIA representation should be created. + * @param verbosity How much detail to include in the description. + * @returns The ARIA representation for the specified block. + */ +export function computeAriaLabel( + block: BlockSvg, + verbosity = Verbosity.STANDARD, +) { + return [ + getBeginStackLabel(block), + getParentInputLabel(block), + ...getInputLabels(block), + verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), + verbosity >= Verbosity.STANDARD && getDisabledLabel(block), + verbosity >= Verbosity.STANDARD && getCollapsedLabel(block), + verbosity >= Verbosity.STANDARD && getShadowBlockLabel(block), + verbosity >= Verbosity.STANDARD && getInputCountLabel(block), + ] + .filter((label) => !!label) + .join(', '); +} + +/** + * Sets the ARIA role and role description for the specified block, accounting + * for whether the block is part of a flyout. + * + * @internal + * @param block The block to set ARIA role and roledescription attributes on. + */ +export function configureAriaRole(block: BlockSvg) { + setRole(block.getSvgRoot(), block.isInFlyout ? Role.LISTITEM : Role.FIGURE); + + let roleDescription = Msg['BLOCK_LABEL_STATEMENT']; + if (block.statementInputCount) { + roleDescription = Msg['BLOCK_LABEL_CONTAINER']; + } else if (block.outputConnection) { + roleDescription = Msg['BLOCK_LABEL_VALUE']; + } + + setState(block.getSvgRoot(), State.ROLEDESCRIPTION, roleDescription); +} + +/** + * Returns a list of ARIA labels for the 'field row' for the specified Input. + * + * 'Field row' essentially means the horizontal run of readable fields that + * precede the Input. Together, these provide the domain context for the input, + * particularly in the context of connections. In some cases, there may not be + * any readable fields immediately prior to the Input. In that case, if the + * `lookback` attribute is specified, all of the fields on the row immediately + * above the Input will be used instead. + * + * @internal + * @param input The Input to compute a description/context label for. + * @param lookback If true, will use labels for fields on the previous row if + * the given input's row has no fields itself. + * @returns A list of labels for fields on the same row (or previous row, if + * lookback is specified) as the given input. + */ +export function computeFieldRowLabel( + input: Input, + lookback: boolean, +): string[] { + const fieldRowLabel = input.fieldRow + .filter((field) => field.isVisible()) + .map((field) => field.computeAriaLabel(true)); + if (!fieldRowLabel.length && lookback) { + const inputs = input.getSourceBlock().inputList; + const index = inputs.indexOf(input); + if (index > 0) { + return computeFieldRowLabel(inputs[index - 1], lookback); + } + } + return fieldRowLabel; +} + +/** + * Returns a description of the parent statement input a block is attached to. + * When a block is connected to a statement input, the input's field row label + * will be prepended to the block's description to indicate that the block + * begins a clause in its parent block. + * + * @internal + * @param block The block to generate a parent input label for. + * @returns A description of the block's parent statement input, or undefined + * for blocks that do not have one. + */ +function getParentInputLabel(block: BlockSvg) { + const parentInput = ( + block.outputConnection ?? block.previousConnection + )?.targetConnection?.getParentInput(); + const parentBlock = parentInput?.getSourceBlock(); + + if (!parentBlock?.statementInputCount) return undefined; + + const firstStatementInput = parentBlock.inputList.find( + (i) => i.type === inputTypes.STATEMENT, + ); + // The first statement input in a block has no field row label as it would + // be duplicative of the block's label. + if (!parentInput || parentInput === firstStatementInput) { + return undefined; + } + + const parentInputLabel = computeFieldRowLabel(parentInput, true); + return parentInput.type === inputTypes.STATEMENT + ? Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', parentInputLabel.join(' ')) + : parentInputLabel; +} + +/** + * Returns text indicating that a block is the root block of a stack. + * + * @internal + * @param block The block to retrieve a label for. + * @returns Text indicating that the block begins a stack, or undefined if it + * does not. + */ +function getBeginStackLabel(block: BlockSvg) { + return !block.workspace.isFlyout && block.getRootBlock() === block + ? Msg['BLOCK_LABEL_BEGIN_STACK'] + : undefined; +} + +/** + * Returns a list of accessibility labels for fields and inputs on a block. + * Each entry in the returned array corresponds to one of: (a) a label for a + * continuous run of non-interactable fields, (b) a label for an editable field, + * (c) a label for an input. When an input contains nested blocks/fields/inputs, + * their contents are returned as a single item in the array per top-level + * input. + * + * @internal + * @param block The block to retrieve a list of field/input labels for. + * @returns A list of field/input labels for the given block. + */ +function getInputLabels(block: BlockSvg): string[] { + return block.inputList + .filter((input) => input.isVisible()) + .flatMap((input) => { + const labels = computeFieldRowLabel(input, false); + + if (input.connection?.type === ConnectionType.INPUT_VALUE) { + const childBlock = input.connection.targetBlock(); + if (childBlock) { + labels.push(getInputLabels(childBlock as BlockSvg).join(' ')); + } + } + + return labels; + }); +} + +/** + * Returns the name of the toolbox category that the given block is part of. + * This is heuristic-based; each toolbox category's contents are enumerated, and + * if a block with the given block's type is encountered, that category is + * deemed to be its parent. As a fallback, a toolbox category with the same + * colour as the block may be returned. This is not comprehensive; blocks may + * exist on the workspace which are not part of any category, or a given block + * type may be part of multiple categories or belong to a dynamically-generated + * category, or there may not even be a toolbox at all. In these cases, either + * the first matching category or undefined will be returned. + * + * This method exists to attempt to provide similar context as block colour + * provides to sighted users, e.g. where a red block comes from a red category. + * It is inherently best-effort due to the above-mentioned constraints. + * + * @internal + * @param block The block to retrieve a category name for. + * @returns A description of the given block's parent toolbox category if any, + * otherwise undefined. + */ +function getParentToolboxCategoryLabel(block: BlockSvg) { + const toolbox = block.workspace.getToolbox(); + if (!toolbox) return undefined; + + let parentCategory: ISelectableToolboxItem | undefined = undefined; + for (const category of toolbox.getToolboxItems()) { + if (!isSelectableToolboxItem(category)) continue; + + const contents = category.getContents(); + if ( + Array.isArray(contents) && + contents.some( + (item) => + item.kind.toLowerCase() === 'block' && + 'type' in item && + item.type === block.type, + ) + ) { + parentCategory = category; + break; + } + + if ( + 'getColour' in category && + typeof category.getColour === 'function' && + category.getColour() === block.getColour() + ) { + parentCategory = category; + } + } + + if (parentCategory) { + return Msg['BLOCK_LABEL_TOOLBOX_CATEGORY'].replace( + '%1', + parentCategory.getName(), + ); + } + + return undefined; +} + +/** + * Returns a label indicating that the block is disabled. + * + * @internal + * @param block The block to generate a label for. + * @returns A label indicating that the block is disabled (if it is), otherwise + * undefined. + */ +export function getDisabledLabel(block: BlockSvg) { + return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED']; +} + +/** + * Returns a label indicating that the block is collapsed. + * + * @internal + * @param block The block to generate a label for. + * @returns A label indicating that the block is collapsed (if it is), otherwise + * undefined. + */ +function getCollapsedLabel(block: BlockSvg) { + return block.isCollapsed() ? Msg['BLOCK_LABEL_COLLAPSED'] : undefined; +} + +/** + * Returns a label indicating that the block is a shadow block. + * + * @internal + * @param block The block to generate a label for. + * @returns A label indicating that the block is a shadow (if it is), otherwise + * undefined. + */ +function getShadowBlockLabel(block: BlockSvg) { + return block.isShadow() ? Msg['BLOCK_LABEL_REPLACEABLE'] : undefined; +} + +/** + * Returns a label indicating whether the block has one or multiple inputs. + * + * @internal + * @param block The block to generate a label for. + * @returns A label indicating that the block has one or multiple inputs, + * otherwise undefined. + */ +function getInputCountLabel(block: BlockSvg) { + const inputCount = block.inputList.reduce((totalSum, input) => { + return ( + input.fieldRow.reduce((fieldCount, field) => { + return field.EDITABLE && !field.isFullBlockField() + ? fieldCount++ + : fieldCount; + }, totalSum) + + (input.connection?.type === ConnectionType.INPUT_VALUE ? 1 : 0) + ); + }, 0); + + switch (inputCount) { + case 0: + return undefined; + case 1: + return Msg['BLOCK_LABEL_HAS_INPUT']; + default: + return Msg['BLOCK_LABEL_HAS_INPUTS']; + } +} diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 0d61501b3..43f47faa6 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -16,6 +16,7 @@ import './events/events_selected.js'; import {Block} from './block.js'; import * as blockAnimations from './block_animations.js'; +import {computeAriaLabel, configureAriaRole} from './block_aria_composer.js'; import * as browserEvents from './browser_events.js'; import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js'; import * as common from './common.js'; @@ -62,6 +63,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'; @@ -244,6 +246,7 @@ export class BlockSvg if (!svg.parentNode) { this.workspace.getCanvas().appendChild(svg); } + this.recomputeAriaAttributes(); this.initialized = true; } @@ -606,6 +609,7 @@ export class BlockSvg this.getInput(collapsedInputName) || this.appendDummyInput(collapsedInputName); input.appendField(new FieldLabel(text), collapsedFieldName); + this.recomputeAriaAttributes(); } /** @@ -842,6 +846,7 @@ export class BlockSvg override setShadow(shadow: boolean) { super.setShadow(shadow); this.applyColour(); + this.recomputeAriaAttributes(); } /** @@ -1062,6 +1067,7 @@ export class BlockSvg for (const child of this.getChildren(false)) { child.updateDisabled(); } + this.recomputeAriaAttributes(); } /** @@ -1885,6 +1891,7 @@ export class BlockSvg /** See IFocusableNode.onNodeFocus. */ onNodeFocus(): void { + this.recomputeAriaAttributes(); this.select(); if (getFocusManager().getFocusedNode() !== this) { renderManagement.finishQueuedRenders().then(() => { @@ -1986,4 +1993,23 @@ export class BlockSvg // All other blocks are their own row. return this.id; } + + /** + * Updates the ARIA label, role and roledescription for this block. + */ + private recomputeAriaAttributes() { + aria.setState(this.getSvgRoot(), aria.State.LABEL, computeAriaLabel(this)); + configureAriaRole(this); + } + + /** + * Returns a description of this block suitable for screenreaders or use in + * ARIA attributes. + * + * @param verbosity How much detail to include in the description. + * @returns An accessibility description of this block. + */ + getAriaLabel(verbosity: aria.Verbosity) { + return computeAriaLabel(this, verbosity); + } } diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 0d3dfd7cc..69e86549f 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -216,6 +216,15 @@ export enum State { VALUEMIN = 'valuemin', } +/** + * Used to control how verbose generated a11y labels are. + */ +export enum Verbosity { + TERSE, + STANDARD, + LOQUACIOUS, +} + /** * Removes the ARIA role from an element. * diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 0716475dc..980cd43d4 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-03 10:36:19.846436", + "lastupdated": "2026-04-09 14:28:47.213464", "locale": "en", "messagedocumentation" : "qqq" }, @@ -427,5 +427,16 @@ "WORKSPACE_CONTENTS_COMMENTS_MANY": " and %1 comments", "WORKSPACE_CONTENTS_COMMENTS_ONE": " and one comment", "KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Use the right arrow key to navigate inside of blocks", - "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate" + "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Use the arrow keys to navigate", + "BLOCK_LABEL_BEGIN_STACK": "Begin stack", + "BLOCK_LABEL_BEGIN_PREFIX": "Begin %1", + "BLOCK_LABEL_TOOLBOX_CATEGORY": "%1 category", + "BLOCK_LABEL_DISABLED": "disabled", + "BLOCK_LABEL_COLLAPSED": "collapsed", + "BLOCK_LABEL_REPLACEABLE": "replaceable", + "BLOCK_LABEL_HAS_INPUT": "has input", + "BLOCK_LABEL_HAS_INPUTS": "has inputs", + "BLOCK_LABEL_STATEMENT": "statement", + "BLOCK_LABEL_CONTAINER": "container", + "BLOCK_LABEL_VALUE": "value" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 6392bd683..e42f1ff5e 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -434,5 +434,16 @@ "WORKSPACE_CONTENTS_COMMENTS_MANY": "ARIA live region phrase appended when there are multiple workspace comments. \n\nParameters:\n* %1 - the number of comments (integer greater than 1)", "WORKSPACE_CONTENTS_COMMENTS_ONE": "ARIA live region phrase appended when there is exactly one workspace comment.", "KEYBOARD_NAV_BLOCK_NAVIGATION_HINT": "Message shown when a user presses Enter with a navigable block focused.", - "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused." + "KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT": "Message shown when a user presses Enter with the workspace focused.", + "BLOCK_LABEL_BEGIN_STACK": "Part of an accessibility label for a block that indicates it is the first block in the stack.", + "BLOCK_LABEL_BEGIN_PREFIX": "Part of an accessibility label for a block that indicates it is the first block inside of a statement input. Placeholder corresponds to the parent statement input's accessibility label.", + "BLOCK_LABEL_TOOLBOX_CATEGORY": "Part of an accessibility label for a block that indicates its parent toolbox category. Placeholder corresponds to a category name, e.g. 'Logic' or 'Math'.", + "BLOCK_LABEL_DISABLED": "Part of an accessibility label for a block that indicates that it is disabled.", + "BLOCK_LABEL_COLLAPSED": "Part of an accessibility label for a block that indicates that it is collapsed.", + "BLOCK_LABEL_REPLACEABLE": "Part of an accessibility label for a block that indicates that it is replaceable, i.e. that it is a shadow block.", + "BLOCK_LABEL_HAS_INPUT": "Part of an accessibility label for a block that indicates that it has a single input.", + "BLOCK_LABEL_HAS_INPUTS": "Part of an accessibility label for a block that indicates that it has more than one input.", + "BLOCK_LABEL_STATEMENT": "Part of an accessibility label for a block that indicates that it is a statement block, i.e. that it has a next or previous connection.", + "BLOCK_LABEL_CONTAINER": "Part of an accessibility label for a block that indicates that it is a container block, i.e. that it has one or more statement inputs.", + "BLOCK_LABEL_VALUE": "Part of an accessibility label for a block that indicates that it is a value block, i.e. that it has an output connection." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index bf36a43c4..e9f7dfa9a 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1724,3 +1724,49 @@ Blockly.Msg.KEYBOARD_NAV_BLOCK_NAVIGATION_HINT = 'Use the right arrow key to nav /** @type {string} */ /// Message shown when a user presses Enter with the workspace focused. Blockly.Msg.KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT = 'Use the arrow keys to navigate'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates it is the first +/// block in the stack. +Blockly.Msg.BLOCK_LABEL_BEGIN_STACK = 'Begin stack'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates it is the first +/// block inside of a statement input. Placeholder corresponds to the parent +/// statement input's accessibility label. +Blockly.Msg.BLOCK_LABEL_BEGIN_PREFIX = 'Begin %1'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates its parent toolbox +/// category. Placeholder corresponds to a category name, e.g. "Logic" or +/// "Math". +Blockly.Msg.BLOCK_LABEL_TOOLBOX_CATEGORY = '%1 category'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// disabled. +Blockly.Msg.BLOCK_LABEL_DISABLED = 'disabled'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// collapsed. +Blockly.Msg.BLOCK_LABEL_COLLAPSED = 'collapsed'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// replaceable, i.e. that it is a shadow block. +Blockly.Msg.BLOCK_LABEL_REPLACEABLE = 'replaceable'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it has a +/// single input. +Blockly.Msg.BLOCK_LABEL_HAS_INPUT = 'has input'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it has more +/// than one input. +Blockly.Msg.BLOCK_LABEL_HAS_INPUTS = 'has inputs'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// a statement block, i.e. that it has a next or previous connection. +Blockly.Msg.BLOCK_LABEL_STATEMENT = 'statement'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// a container block, i.e. that it has one or more statement inputs. +Blockly.Msg.BLOCK_LABEL_CONTAINER = 'container'; +/** @type {string} */ +/// Part of an accessibility label for a block that indicates that it is +/// a value block, i.e. that it has an output connection. +Blockly.Msg.BLOCK_LABEL_VALUE = 'value'; diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 806b5d7c0..3fc959753 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -10,10 +10,24 @@ import { sharedTestTeardown, } from './test_helpers/setup_teardown.js'; -suite('Aria', function () { +suite('ARIA', function () { setup(function () { sharedTestSetup.call(this); - this.workspace = Blockly.inject('blocklyDiv', {}); + Blockly.defineBlocksWithJsonArray([ + { + type: 'basic_block', + message0: '%1', + args0: [ + { + type: 'field_input', + name: 'TEXT', + text: 'default', + }, + ], + }, + ]); + const toolbox = document.getElementById('toolbox-categories'); + this.workspace = Blockly.inject('blocklyDiv', {toolbox}); this.liveRegion = document.getElementById('blocklyAriaAnnounce'); }); @@ -263,4 +277,177 @@ suite('Aria', function () { assert.equal(element.getAttribute('aria-label'), 'one two three'); }); }); + + suite('Blocks', function () { + setup(function () { + this.makeBlock = (blockType) => { + const block = this.workspace.newBlock(blockType); + block.initSvg(); + block.render(); + Blockly.getFocusManager().focusNode(block); + return block; + }; + }); + + test('Statement blocks have correct role description', function () { + const block = this.makeBlock('text_print'); + const roleDescription = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.ROLEDESCRIPTION, + ); + assert.equal(roleDescription, 'statement'); + }); + + test('Value blocks have correct role description', function () { + const block = this.makeBlock('logic_boolean'); + const roleDescription = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.ROLEDESCRIPTION, + ); + assert.equal(roleDescription, 'value'); + }); + + test('Container blocks have correct role description', function () { + const block = this.makeBlock('controls_if'); + const roleDescription = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.ROLEDESCRIPTION, + ); + assert.equal(roleDescription, 'container'); + }); + + test('Workspace blocks have the correct role', function () { + const block = this.makeBlock('text_print'); + const role = Blockly.utils.aria.getRole(block.getSvgRoot()); + assert.equal(role, Blockly.utils.aria.Role.FIGURE); + }); + + test('Flyout blocks have the correct role', function () { + Blockly.getFocusManager().focusNode( + this.workspace.getToolbox().getToolboxItems()[0], + ); + const block = this.workspace.getFlyout().getWorkspace().getTopBlocks()[0]; + const role = Blockly.utils.aria.getRole(block.getSvgRoot()); + assert.equal(role, Blockly.utils.aria.Role.LISTITEM); + }); + + test('Root workspace blocks indicate that in their labels', function () { + const block = this.makeBlock('text_print'); + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isTrue(label.startsWith('Begin stack')); + }); + + test('Flyout blocks are not labeled as beginning a stack', function () { + Blockly.getFocusManager().focusNode( + this.workspace.getToolbox().getToolboxItems()[0], + ); + const block = this.workspace.getFlyout().getWorkspace().getTopBlocks()[0]; + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'Begin stack'); + }); + + test('Nested statement blocks in first statement input do not include their parent input in their label', function () { + const ifBlock = this.makeBlock('controls_ifelse'); + const printBlock = this.makeBlock('text_print'); + ifBlock.getInput('IF0').connection.connect(printBlock.previousConnection); + const label = Blockly.utils.aria.getState( + printBlock.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isFalse(label.startsWith('Begin do')); + }); + + test('Nested statement blocks in subsequent statement inputs include their parent input in their label', function () { + const ifBlock = this.makeBlock('controls_ifelse'); + const printBlock = this.makeBlock('text_print'); + ifBlock + .getInput('ELSE') + .connection.connect(printBlock.previousConnection); + const label = Blockly.utils.aria.getState( + printBlock.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isTrue(label.startsWith('Begin else')); + }); + + test('Disabled blocks indicate that in their label', function () { + const block = this.makeBlock('text_print'); + let label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'disabled'); + block.setDisabledReason(true, 'testing'); + label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.include(label, 'disabled'); + }); + + test('Collapsed blocks indicate that in their label', function () { + const block = this.makeBlock('text_print'); + let label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'collapsed'); + block.setCollapsed(true); + label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.include(label, 'collapsed'); + }); + + test('Shadow blocks indicate that in their label', function () { + const block = this.makeBlock('text_print'); + const text = this.makeBlock('text'); + text.outputConnection.connect(block.inputList[0].connection); + let label = Blockly.utils.aria.getState( + text.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'replaceable'); + text.setShadow(true); + label = Blockly.utils.aria.getState( + text.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.include(label, 'replaceable'); + }); + + test('Blocks without inputs are properly labeled', function () { + const block = this.makeBlock('math_random_float'); + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.notInclude(label, 'input'); + }); + + test('Blocks with one input are properly labeled', function () { + const block = this.makeBlock('logic_negate'); + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isTrue(label.endsWith('has input')); + }); + + test('Blocks with multiple inputs are properly labeled', function () { + const block = this.makeBlock('logic_ternary'); + const label = Blockly.utils.aria.getState( + block.getSvgRoot(), + Blockly.utils.aria.State.LABEL, + ); + assert.isTrue(label.endsWith('has inputs')); + }); + }); }); From 0c4ec192baa6f9b06834fac0f4ddcadcdfcce4e8 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 15 Apr 2026 08:29:43 -0700 Subject: [PATCH 047/200] fix: Fix bug that caused blocks inserted via Enter to not attach (#9699) --- packages/blockly/core/block_svg.ts | 4 ++-- packages/blockly/core/interfaces/i_focusable_node.ts | 4 +++- packages/blockly/core/shortcut_items.ts | 2 +- packages/blockly/tests/mocha/shortcut_items_test.js | 10 ++++++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 43f47faa6..8a682ab39 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -1918,9 +1918,9 @@ export class BlockSvg * main workspace. If this block has a single full-block field, that field * will be focused. Otherwise, this is a no-op. */ - performAction() { + performAction(e?: KeyboardEvent) { if (this.workspace.isFlyout) { - KeyboardMover.mover.startMove(this); + KeyboardMover.mover.startMove(this, e); return; } else if (this.isSimpleReporter()) { for (const input of this.inputList) { diff --git a/packages/blockly/core/interfaces/i_focusable_node.ts b/packages/blockly/core/interfaces/i_focusable_node.ts index 37dd08bc4..affbf61b2 100644 --- a/packages/blockly/core/interfaces/i_focusable_node.ts +++ b/packages/blockly/core/interfaces/i_focusable_node.ts @@ -104,8 +104,10 @@ export interface IFocusableNode { * Optional method invoked when this node has focus and the user acts on it by * pressing Enter or Space. Behavior should generally be similar to the node * being clicked on. + * + * @param e The event that triggered this action, if any. */ - performAction?(): void; + performAction?(e?: Event): void; } /** diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 0c594d0f9..14ebd3e20 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -861,7 +861,7 @@ export function registerPerformAction() { const focusedNode = getFocusManager().getFocusedNode(); if (focusedNode && 'performAction' in focusedNode) { e.preventDefault(); - focusedNode.performAction?.(); + focusedNode.performAction?.(e); return true; } return false; diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 8df3e662f..67e0d2593 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -1037,6 +1037,10 @@ suite('Keyboard Shortcut Items', function () { }); test('Inserts blocks from the flyout in move mode', function () { + const first = this.workspace.newBlock('stack_block'); + first.initSvg(); + first.render(); + this.workspace.getToolbox().selectItemByPosition(0); const block = this.workspace .getNavigator() @@ -1053,6 +1057,12 @@ suite('Keyboard Shortcut Items', function () { assert.isTrue(movingBlock.isDragging()); assert.isFalse(movingBlock.workspace.isFlyout); + const hasInsertionMarker = this.workspace + .getTopBlocks() + .flatMap((b) => b.getChildren()) + .some((b) => b.isInsertionMarker()); + assert.isTrue(hasInsertionMarker); + Blockly.KeyboardMover.mover.abortMove(); }); From af9cdea250616f1975772c102672779d0fbbb984 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 15 Apr 2026 09:15:16 -0700 Subject: [PATCH 048/200] chore: Fix import paths (#9723) --- packages/blockly/core/clipboard/block_paster.ts | 2 +- packages/blockly/core/field_input.ts | 2 +- .../blockly/core/keyboard_nav/navigators/flyout_navigator.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/blockly/core/clipboard/block_paster.ts b/packages/blockly/core/clipboard/block_paster.ts index e782cc0b0..3ee14f7e7 100644 --- a/packages/blockly/core/clipboard/block_paster.ts +++ b/packages/blockly/core/clipboard/block_paster.ts @@ -5,12 +5,12 @@ */ import {BlockSvg} from '../block_svg.js'; -import {IFocusableNode} from '../blockly.js'; import {config} from '../config.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import {getFocusManager} from '../focus_manager.js'; import {ICopyData} from '../interfaces/i_copyable.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import {IPaster} from '../interfaces/i_paster.js'; import * as renderManagement from '../render_management.js'; import {State, append} from '../serialization/blocks.js'; diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index dca834fb9..ac3717b59 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -15,7 +15,6 @@ import './events/events_block_change.js'; import {BlockSvg} from './block_svg.js'; -import {IFocusableNode} from './blockly.js'; import * as browserEvents from './browser_events.js'; import * as bumpObjects from './bump_objects.js'; import * as dialog from './dialog.js'; @@ -29,6 +28,7 @@ import { UnattachedFieldError, } from './field.js'; import {getFocusManager} from './focus_manager.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; import * as aria from './utils/aria.js'; diff --git a/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts index a49d18c04..4a5002013 100644 --- a/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/flyout_navigator.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {IFocusableNode} from '../../blockly.js'; import type {IFlyout} from '../../interfaces/i_flyout.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; import {Position} from '../../utils/toolbox.js'; import {FlyoutButtonNavigationPolicy} from '../navigation_policies/flyout_button_navigation_policy.js'; import {FlyoutSeparatorNavigationPolicy} from '../navigation_policies/flyout_separator_navigation_policy.js'; From 743cdd8ff390e066e94463c9e6ec9f6376841e87 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 15 Apr 2026 09:17:44 -0700 Subject: [PATCH 049/200] fix: Inhibit keyboard navigation shortcuts when the dropdown or widget divs are open (#9724) --- packages/blockly/core/shortcut_items.ts | 37 +++++++-- .../tests/mocha/keyboard_navigation_test.js | 31 +++++++ .../tests/mocha/shortcut_items_test.js | 83 ++++++++++++++++++- 3 files changed, 143 insertions(+), 8 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 14ebd3e20..af82248d4 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -10,6 +10,7 @@ import {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; import {RenderedWorkspaceComment} from './comments.js'; import * as contextmenu from './contextmenu.js'; +import * as dropDownDiv from './dropdowndiv.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; import {hasContextMenu} from './interfaces/i_contextmenu.js'; @@ -27,6 +28,7 @@ import {Coordinate} from './utils/coordinate.js'; import {KeyCodes} from './utils/keycodes.js'; import {Rect} from './utils/rect.js'; import * as svgMath from './utils/svg_math.js'; +import * as widgetDiv from './widgetdiv.js'; import {WorkspaceSvg} from './workspace_svg.js'; /** @@ -574,7 +576,10 @@ export function registerArrowNavigation() { /** Go to the next location to the right. */ right: { name: names.NAVIGATE_RIGHT, - preconditionFn: (workspace) => !workspace.isDragging(), + preconditionFn: (workspace) => + !workspace.isDragging() && + !dropDownDiv.isVisible() && + !widgetDiv.isVisible(), callback: (workspace, e) => { e.preventDefault(); keyboardNavigationController.setIsActive(true); @@ -592,7 +597,10 @@ export function registerArrowNavigation() { /** Go to the next location to the left. */ left: { name: names.NAVIGATE_LEFT, - preconditionFn: (workspace) => !workspace.isDragging(), + preconditionFn: (workspace) => + !workspace.isDragging() && + !dropDownDiv.isVisible() && + !widgetDiv.isVisible(), callback: (workspace, e) => { e.preventDefault(); keyboardNavigationController.setIsActive(true); @@ -610,7 +618,10 @@ export function registerArrowNavigation() { /** Go down to the next location. */ down: { name: names.NAVIGATE_DOWN, - preconditionFn: (workspace) => !workspace.isDragging(), + preconditionFn: (workspace) => + !workspace.isDragging() && + !dropDownDiv.isVisible() && + !widgetDiv.isVisible(), callback: (_workspace, e) => { e.preventDefault(); keyboardNavigationController.setIsActive(true); @@ -628,7 +639,10 @@ export function registerArrowNavigation() { /** Go up to the previous location. */ up: { name: names.NAVIGATE_UP, - preconditionFn: (workspace) => !workspace.isDragging(), + preconditionFn: (workspace) => + !workspace.isDragging() && + !dropDownDiv.isVisible() && + !widgetDiv.isVisible(), callback: (_workspace, e) => { e.preventDefault(); keyboardNavigationController.setIsActive(true); @@ -810,7 +824,10 @@ export function registerStackNavigation() { const nextStackShortcut: KeyboardShortcut = { name: names.NEXT_STACK, preconditionFn: (workspace) => - !workspace.isDragging() && !!resolveStack(workspace), + !workspace.isDragging() && + !!resolveStack(workspace) && + !dropDownDiv.isVisible() && + !widgetDiv.isVisible(), callback: (workspace) => { keyboardNavigationController.setIsActive(true); const start = resolveStack(workspace); @@ -826,7 +843,10 @@ export function registerStackNavigation() { const previousStackShortcut: KeyboardShortcut = { name: names.PREVIOUS_STACK, preconditionFn: (workspace) => - !workspace.isDragging() && !!resolveStack(workspace), + !workspace.isDragging() && + !!resolveStack(workspace) && + !dropDownDiv.isVisible() && + !widgetDiv.isVisible(), callback: (workspace) => { keyboardNavigationController.setIsActive(true); const start = resolveStack(workspace); @@ -855,7 +875,10 @@ export function registerStackNavigation() { export function registerPerformAction() { const performActionShortcut: KeyboardShortcut = { name: names.PERFORM_ACTION, - preconditionFn: (workspace) => !workspace.isDragging(), + preconditionFn: (workspace) => + !workspace.isDragging() && + !dropDownDiv.isVisible() && + !widgetDiv.isVisible(), callback: (_workspace, e) => { keyboardNavigationController.setIsActive(true); const focusedNode = getFocusManager().getFocusedNode(); diff --git a/packages/blockly/tests/mocha/keyboard_navigation_test.js b/packages/blockly/tests/mocha/keyboard_navigation_test.js index 9d5614ca9..58fb3d935 100644 --- a/packages/blockly/tests/mocha/keyboard_navigation_test.js +++ b/packages/blockly/tests/mocha/keyboard_navigation_test.js @@ -255,6 +255,37 @@ suite('Keyboard navigation on Blocks', function () { pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); assert.include(getFocusNodeId(), 'text_1_field_'); }); + + test('Is inhibited when widgetdiv is visible', function () { + focusBlock(this.workspace, 'text_print_1'); + this.workspace.getBlockById('text_print_1').showContextMenu(); + assert.isTrue(Blockly.WidgetDiv.isVisible()); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'text_print_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'text_print_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'text_print_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'text_print_1'); + }); + + test('Is inhibited when dropdowndiv is visible', function () { + focusBlock(this.workspace, 'logic_boolean_1'); + this.workspace + .getBlockById('logic_boolean_1') + .getField('BOOL') + .showEditor(); + assert.isTrue(Blockly.DropDownDiv.isVisible()); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(getFocusedBlockId(), 'logic_boolean_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(getFocusedBlockId(), 'logic_boolean_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.UP); + assert.equal(getFocusedBlockId(), 'logic_boolean_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.DOWN); + assert.equal(getFocusedBlockId(), 'logic_boolean_1'); + }); }); suite('Keyboard navigation on Fields', function () { diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 67e0d2593..3b2154eb3 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -871,11 +871,16 @@ suite('Keyboard Shortcut Items', function () { setup(function () { this.block1 = this.workspace.newBlock('controls_if'); - this.block2 = this.workspace.newBlock('stack_block'); + this.block2 = this.workspace.newBlock('logic_compare'); this.block3 = this.workspace.newBlock('stack_block'); this.block2.moveBy(0, 100); this.block3.moveBy(0, 400); + for (const block of [this.block1, this.block2, this.block3]) { + block.initSvg(); + block.render(); + } + this.comment1 = this.workspace.newComment(); this.comment2 = this.workspace.newComment(); this.comment1.moveBy(0, 200); @@ -1017,6 +1022,50 @@ suite('Keyboard Shortcut Items', function () { this.block2, ); }); + + test('Navigating forward is inhibited when widgetdiv is visible', function () { + Blockly.getFocusManager().focusNode(this.block2); + this.block2.showContextMenu(); + assert.isTrue(Blockly.WidgetDiv.isVisible()); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); + + test('Navigating forward is inhibited when dropdowndiv is visible', function () { + Blockly.getFocusManager().focusNode(this.block2); + this.block2.getField('OP').showEditor(); + assert.isTrue(Blockly.DropDownDiv.isVisible()); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); + + test('Navigating backward is inhibited when widgetdiv is visible', function () { + Blockly.getFocusManager().focusNode(this.block2); + this.block2.showContextMenu(); + assert.isTrue(Blockly.WidgetDiv.isVisible()); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); + + test('Navigating backward is inhibited when dropdowndiv is visible', function () { + Blockly.getFocusManager().focusNode(this.block2); + this.block2.getField('OP').showEditor(); + assert.isTrue(Blockly.DropDownDiv.isVisible()); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); }); suite('Perform Action (Enter)', function () { @@ -1240,6 +1289,38 @@ suite('Keyboard Shortcut Items', function () { assert.isTrue(called); this.workspace.registerButtonCallback('CREATE_VARIABLE', oldCallback); }); + + test('Is inhibited when dropdowndiv is visible', function () { + const block = this.workspace.newBlock('logic_compare'); + block.initSvg(); + block.render(); + const field = block.getField('OP'); + Blockly.getFocusManager().focusNode(field); + field.showEditor(); + + assert.isTrue(Blockly.DropDownDiv.isVisible()); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + assert.isTrue(Blockly.DropDownDiv.isVisible()); + }); + + test('Is inhibited when widgetdiv is visible', function () { + const block = this.workspace.newBlock('logic_compare'); + block.initSvg(); + block.render(); + const field = block.getField('OP'); + block.showContextMenu(); + Blockly.getFocusManager().focusNode(field); + + assert.isTrue(Blockly.WidgetDiv.isVisible()); + + const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); + this.workspace.getInjectionDiv().dispatchEvent(event); + + assert.isTrue(Blockly.WidgetDiv.isVisible()); + }); }); suite('Duplicate (D)', function () { From 0e0a5169904613060f24758cdd9b0020ec314770 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 15 Apr 2026 09:18:34 -0700 Subject: [PATCH 050/200] fix: Increase contrast of active tree focus ring color (#9726) --- packages/blockly/core/css.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index aeb0e356a..beeded18b 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -529,7 +529,7 @@ input[type=number] { .injectionDiv { --blockly-active-node-color: #fff200; - --blockly-active-tree-color: #60a5fa; + --blockly-active-tree-color: #1379f6; --blockly-selection-width: 3px; } From 1de9aa920ea2eb7dd54914f5a31400fe309adb2e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 15 Apr 2026 09:19:09 -0700 Subject: [PATCH 051/200] fix: Fix bug that caused blocks in flyout to disappear on click in Chrome (#9729) --- packages/blockly/core/gesture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/core/gesture.ts b/packages/blockly/core/gesture.ts index 8628f938f..31feda7a0 100644 --- a/packages/blockly/core/gesture.ts +++ b/packages/blockly/core/gesture.ts @@ -1004,8 +1004,8 @@ export class Gesture { this.setTargetBlock(block.getParent()!); } else { this.targetBlock = block; - getFocusManager().focusNode(this.targetBlock); this.targetBlock.bringToFront(); + getFocusManager().focusNode(this.targetBlock); } } From 2903ec9f3a5237e1d80c3c1845356b08a02df8c0 Mon Sep 17 00:00:00 2001 From: lizschwab Date: Fri, 17 Apr 2026 15:18:23 -0700 Subject: [PATCH 052/200] fix: Field text hidden from ARIA (#9734) --- packages/blockly/core/field.ts | 7 +++++-- packages/blockly/tests/mocha/field_test.js | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index 295a13863..9f218288f 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -476,15 +476,18 @@ export abstract class Field } /** - * Create a field text element. Not to be overridden by subclasses. Instead + * Create a field text element. Not to be overridden by subclasses. Instead, * modify the result of the function inside initView, or create a separate - * function to call. + * function to call. Aria state is hidden; use the aria label for the field + * and/or containing block to expose content to screen readers. Text content + * for custom blocks can be set after creation. */ protected createTextElement_() { this.textElement_ = dom.createSvgElement( Svg.TEXT, { 'class': 'blocklyText blocklyFieldText', + 'aria-hidden': 'true', }, this.fieldGroup_, ); diff --git a/packages/blockly/tests/mocha/field_test.js b/packages/blockly/tests/mocha/field_test.js index e2163a2a3..7736c6b50 100644 --- a/packages/blockly/tests/mocha/field_test.js +++ b/packages/blockly/tests/mocha/field_test.js @@ -944,5 +944,14 @@ suite('Abstract Fields', function () { assert.equal(field.computeAriaLabel(true), 'custom type: custom value'); }); }); + + suite('Field text elements are hidden', function () { + test('Field text element has aria-hidden=true', function () { + const field = new TestField(); + field.constants_ = {FIELD_BORDER_RECT_RADIUS: 5}; + field.initView(); + assert(field.getTextElement().ariaHidden === 'true'); + }); + }); }); }); From 16767eaaa2cb2def7ce4b746f930a832d4b9ee48 Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:12:02 -0400 Subject: [PATCH 053/200] feat: Screenreader announcements for move mode (#9731) * feat: Screenreader announcements for move mode * fix: lint * fix: update docstrings * fix: code review changes * fix: add block id to error --- packages/blockly/core/block_aria_composer.ts | 211 +++++++++++++++- packages/blockly/core/block_svg.ts | 21 ++ .../core/dragging/block_drag_strategy.ts | 76 +++++- packages/blockly/core/field.ts | 49 ++-- packages/blockly/core/inputs/input.ts | 32 +++ packages/blockly/msg/json/en.json | 14 +- packages/blockly/msg/json/qqq.json | 12 +- packages/blockly/msg/messages.js | 52 ++++ packages/blockly/tests/mocha/field_test.js | 49 +++- .../tests/mocha/keyboard_movement_test.js | 227 ++++++++++++++++++ 10 files changed, 682 insertions(+), 61 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index fa06c4894..925512307 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -5,6 +5,7 @@ */ import type {BlockSvg} from './block_svg.js'; +import {RenderedConnection} from './blockly.js'; import {ConnectionType} from './connection_type.js'; import type {Input} from './inputs/input.js'; import {inputTypes} from './inputs/input_types.js'; @@ -15,6 +16,18 @@ import { import {Msg} from './msg.js'; import {Role, setRole, setState, State, Verbosity} from './utils/aria.js'; +/** + * Prepositions to use when describing the relationship between two blocks based + * on their connection types. + */ +export enum ConnectionPreposition { + UNKNOWN, + BEFORE, + AFTER, + AROUND, + INSIDE, +} + /** * Returns an ARIA representation of the specified block. * @@ -45,7 +58,7 @@ export function computeAriaLabel( verbosity = Verbosity.STANDARD, ) { return [ - getBeginStackLabel(block), + verbosity >= Verbosity.STANDARD && getBeginStackLabel(block), getParentInputLabel(block), ...getInputLabels(block), verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), @@ -129,6 +142,7 @@ function getParentInputLabel(block: BlockSvg) { )?.targetConnection?.getParentInput(); const parentBlock = parentInput?.getSourceBlock(); + if (parentBlock?.isInsertionMarker()) return undefined; if (!parentBlock?.statementInputCount) return undefined; const firstStatementInput = parentBlock.inputList.find( @@ -172,21 +186,76 @@ function getBeginStackLabel(block: BlockSvg) { * @param block The block to retrieve a list of field/input labels for. * @returns A list of field/input labels for the given block. */ -function getInputLabels(block: BlockSvg): string[] { +export function getInputLabels(block: BlockSvg): string[] { return block.inputList .filter((input) => input.isVisible()) - .flatMap((input) => { - const labels = computeFieldRowLabel(input, false); + .map((input) => input.getLabel()); +} - if (input.connection?.type === ConnectionType.INPUT_VALUE) { - const childBlock = input.connection.targetBlock(); - if (childBlock) { - labels.push(getInputLabels(childBlock as BlockSvg).join(' ')); - } - } +/** + * Returns a subset of labels for inputs on the given block, ending at the + * specified input. + * + * The subset is determined based on the input type: + * - For non-statement inputs, only the label for the given input is returned. + * - For statement inputs, labels are collected from the start of the current + * statement section up to and including the given input. A statement section + * begins immediately after the previous statement input, or at the start of + * the block if none exists. + * + * @internal + * @param block The block to retrieve a list of field/input labels for. + * @param input The input that defines the end of the subset. + * @returns A list of field/input labels for the given block. + */ +export function getInputLabelsSubset(block: BlockSvg, input: Input): string[] { + const inputIndex = block.inputList.indexOf(input); + if (inputIndex === -1) { + throw new Error( + `Input with name "${input.name}" not found on block with id "${block.id}".`, + ); + } - return labels; - }); + const startIndex = + input.type === inputTypes.STATEMENT + ? findStartOfStatementSection(block.inputList, inputIndex) + : inputIndex; + + return block.inputList + .slice(startIndex, inputIndex + 1) + .filter((input) => input.isVisible()) + .map( + (input) => + input.getLabel() || + Msg['INPUT_LABEL_INDEX'].replace( + '%1', + (input.getIndex() + 1).toString(), + ), + ); +} + +/** + * Finds the starting index of the current statement section within a list of inputs. + * + * A statement section is defined as the group of inputs that follow the most + * recent preceding statement input. If no prior statement input exists, the + * section starts at index 0. + * + * @param inputs The list of inputs to search. + * @param fromIndex The index of the current statement input. + * @returns The index of the first input in the current statement section. + */ +function findStartOfStatementSection( + inputs: Input[], + fromIndex: number, +): number { + // Find the first input after the previous statement input. + for (let i = fromIndex - 1; i >= 0; i--) { + if (inputs[i].type === inputTypes.STATEMENT) { + return i + 1; + } + } + return 0; } /** @@ -250,6 +319,122 @@ function getParentToolboxCategoryLabel(block: BlockSvg) { return undefined; } +/** + * Returns a translated string describing an in-progress move of a block to a new + * connection, suitable for announcement on the ARIA live region. The returned string + * will be assembled based on the types of the local and neighbour connections and + * the presence of any readable fields on the block's inputs. If multiple potential + * candidate connections are present, additional context will be included in the + * returned string to help disambiguate between them. + * + * @param local The moving side of the candidate connection pair + * @param neighbour The target side of the candidate connection pair + * @param disambiguationPolicy A function that determines whether it's useful to + * include parent input labels for disambiguation. + * @param isMoveStart Whether this announcement is for the start of a move. If false, + * skip announcing the block label since it should have already been announced. + */ +export function computeMoveLabel( + local: RenderedConnection, + neighbour: RenderedConnection, + disambiguationPolicy: (forLocal: boolean) => boolean, + isMoveStart = false, +): string { + const preposition = getConnectionPreposition(local, neighbour); + const neighbourBlock = neighbour.getSourceBlock() as BlockSvg; + const neighbourBlockLabel = neighbourBlock.getAriaLabel(Verbosity.TERSE); + const blockLabel = isMoveStart + ? local.getSourceBlock().getStackBlocksCountLabel() + : ''; + + let announcementTemplate; + // Message strings take a format like 'moving %1 %2 to %3 %4' where: + // "to" is replaced with a preposition based on the type of the connection candidate + // (e.g. "before", "after", "inside", "around", etc), and the placeholders are replaced with: + // %1 = optional label for the block being moved + // %2 = optional label for the local connection + // %3 = label for the neighbour block + // %4 = optional label for the neighbour connection + switch (preposition) { + case ConnectionPreposition.BEFORE: + announcementTemplate = Msg['ANNOUNCE_MOVE_BEFORE']; + break; + case ConnectionPreposition.AFTER: + announcementTemplate = Msg['ANNOUNCE_MOVE_AFTER']; + break; + case ConnectionPreposition.INSIDE: + announcementTemplate = Msg['ANNOUNCE_MOVE_INSIDE']; + break; + case ConnectionPreposition.AROUND: + announcementTemplate = Msg['ANNOUNCE_MOVE_AROUND']; + break; + case ConnectionPreposition.UNKNOWN: + announcementTemplate = Msg['ANNOUNCE_MOVE_UNKNOWN']; + } + + // If multiple compatible candidate connections exist for either/both pairs of the + // current connection candidate, increase the verbosity of the announcement to help + // disambiguate them. + const requiresDisambiguation = [ + ConnectionPreposition.INSIDE, + ConnectionPreposition.AROUND, + ].includes(preposition); + const describeLocal = requiresDisambiguation && disambiguationPolicy(true); + const describeNeighbour = + requiresDisambiguation && disambiguationPolicy(false); + + const localInput = local.getParentInput(); + const neighbourInput = neighbour.getParentInput(); + + const localConnLabel = + (describeLocal && + localInput && + getInputLabelsSubset(local.getSourceBlock(), localInput).join(', ')) || + ''; + const neighbourConnLabel = + (describeNeighbour && + neighbourInput && + getInputLabelsSubset(neighbourBlock, neighbourInput).join(', ')) || + ''; + + return announcementTemplate + .replace('%1', blockLabel) + .replace('%2', localConnLabel) + .replace('%3', neighbourBlockLabel) + .replace('%4', neighbourConnLabel); +} + +/** + * Returns the appropriate preposition to use in the move announcement based on the + * relationship between the local and neighbour connections. + */ +function getConnectionPreposition( + local: RenderedConnection, + neighbour: RenderedConnection, +): ConnectionPreposition { + switch (local.type) { + case ConnectionType.OUTPUT_VALUE: + return ConnectionPreposition.INSIDE; + case ConnectionType.INPUT_VALUE: + return ConnectionPreposition.AROUND; + case ConnectionType.NEXT_STATEMENT: + if (local === local.getSourceBlock().nextConnection) { + return ConnectionPreposition.BEFORE; + } else { + return ConnectionPreposition.AROUND; + } + case ConnectionType.PREVIOUS_STATEMENT: + if (neighbour === neighbour.getSourceBlock().nextConnection) { + return ConnectionPreposition.AFTER; + } else { + return ConnectionPreposition.INSIDE; + } + } + // Not normally reachable since we should always have a connection candidate + // with valid connection types. Satisfies the return type. + return ConnectionPreposition.UNKNOWN; +} + /** * Returns a label indicating that the block is disabled. * @@ -258,7 +443,7 @@ function getParentToolboxCategoryLabel(block: BlockSvg) { * @returns A label indicating that the block is disabled (if it is), otherwise * undefined. */ -export function getDisabledLabel(block: BlockSvg) { +function getDisabledLabel(block: BlockSvg) { return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED']; } diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 8a682ab39..3b4aac5c1 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -2012,4 +2012,25 @@ export class BlockSvg getAriaLabel(verbosity: aria.Verbosity) { return computeAriaLabel(this, verbosity); } + + /** + * Count the number of blocks in this stack (connected by next connections) + * and return a label to describe it. Uses the standard label if there is only one block. + * + * @internal + */ + getStackBlocksCountLabel(): string { + let count = 1; + let block = this.getNextBlock(); + while (block) { + count++; + block = block.getNextBlock(); + } + if (count <= 1) { + return this.getAriaLabel(aria.Verbosity.TERSE); + } + + const labelTemplate = Msg['BLOCK_LABEL_STACK_BLOCKS']; + return labelTemplate.replace('%1', count.toString()); + } } diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index c521e8b11..fc09eb8eb 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -6,6 +6,7 @@ import type {Block} from '../block.js'; import * as blockAnimation from '../block_animations.js'; +import {computeMoveLabel} from '../block_aria_composer.js'; import type {BlockSvg} from '../block_svg.js'; import * as bumpObjects from '../bump_objects.js'; import {config} from '../config.js'; @@ -22,11 +23,13 @@ import {DragDisposition} from '../interfaces/i_draggable.js'; import {IHasBubble, hasBubble} from '../interfaces/i_has_bubble.js'; import {Direction} from '../keyboard_nav/keyboard_mover.js'; import * as layers from '../layers.js'; +import {Msg} from '../msg.js'; import * as registry from '../registry.js'; import {finishQueuedRenders} from '../render_management.js'; import type {RenderedConnection} from '../rendered_connection.js'; import * as blocks from '../serialization/blocks.js'; import {Coordinate} from '../utils.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import * as svgMath from '../utils/svg_math.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -153,6 +156,58 @@ export class BlockDragStrategy implements IDragStrategy { return this.block; } + /** + * Announces a move on the ARIA live region for assistive technologies. + * + * @param isMoveStart Whether this announcement is for the start of a move. If false, + * skip announcing the block label since it should have already been announced at the + * start of the move. + */ + private announceMove(isMoveStart: boolean = false) { + let announcementTemplate = ''; + let announcement = ''; + if (this.connectionCandidate) { + announcement = computeMoveLabel( + this.connectionCandidate.local, + this.connectionCandidate.neighbour, + this.hasMultipleCompatibleConnections.bind(this), + isMoveStart, + ); + } else { + const blockLabel = isMoveStart + ? this.block.getStackBlocksCountLabel() + : ''; + announcementTemplate = Msg['ANNOUNCE_MOVE_WORKSPACE']; + announcement = announcementTemplate.replace('%1', blockLabel); + } + // Collapse whitespace from unused template substitutions. + aria.announceDynamicAriaState(announcement.replace(/\s+/g, ' ')); + } + + /** + * Checks if there are multiple compatible connections for the specified side of the pair. + * + * @param forLocal Whether we are considering the local or neighbour side of the pair + * @returns True if there are multiple compatible connections, false otherwise + */ + private hasMultipleCompatibleConnections(forLocal: boolean = true): boolean { + const connectionCandidate = this.connectionCandidate; + if (!connectionCandidate) { + return false; + } + const currentSide = forLocal ? 'local' : 'neighbour'; + const oppositeSide = forLocal ? 'neighbour' : 'local'; + + const filteredPairs = this.allConnectionPairs.filter( + (pair) => + pair[oppositeSide] === connectionCandidate[oppositeSide] && + pair[currentSide] !== + connectionCandidate[currentSide].getSourceBlock().nextConnection && + pair[currentSide].getSourceBlock().id === + connectionCandidate[currentSide].getSourceBlock().id, + ); + return filteredPairs.length > 1; + } /** * Handles any setup for starting the drag, including disconnecting the block * from any parent blocks. @@ -222,6 +277,7 @@ export class BlockDragStrategy implements IDragStrategy { } } + this.announceMove(true); return this.block; } @@ -460,6 +516,7 @@ export class BlockDragStrategy implements IDragStrategy { this.workspace.getAudioManager().playErrorBeep(); } } + this.announceMove(); } /** @@ -767,6 +824,7 @@ export class BlockDragStrategy implements IDragStrategy { this.block.setDragging(false); this.dragging = false; + aria.announceDynamicAriaState(Msg['ANNOUNCE_MOVE_CANCELED']); } /** @@ -824,19 +882,11 @@ export class BlockDragStrategy implements IDragStrategy { const actualPosition = this.block.getRelativeToSurfaceXY(); const delta = Coordinate.difference(newLocation, actualPosition); const {x, y} = delta; - if (x) { - if (x < 0) { - return Direction.LEFT; - } else if (x > 0) { - return Direction.RIGHT; - } - } else if (y) { - if (y < 0) { - return Direction.UP; - } else if (y > 0) { - return Direction.DOWN; - } - } + + if (x < 0) return Direction.LEFT; + if (x > 0) return Direction.RIGHT; + if (y < 0) return Direction.UP; + if (y > 0) return Direction.DOWN; return Direction.NONE; } diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index 9f218288f..e8b83c574 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -28,6 +28,7 @@ import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js'; import type {IRegistrable} from './interfaces/i_registrable.js'; import {ISerializable} from './interfaces/i_serializable.js'; +import {Msg} from './msg.js'; import type {ConstantProvider} from './renderers/common/constants.js'; import type {KeyboardShortcut} from './shortcut_registry.js'; import * as Tooltip from './tooltip.js'; @@ -316,38 +317,34 @@ export abstract class Field * unspecified. */ getAriaTypeName(): string | null { - return this.ariaTypeName; + return this.ariaTypeName || null; } /** * Gets 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 + * ensure a non-null value is returned. The default implementation relies on + * 'getText' which may return an empty string. A null return value from 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. + * entirely when there may be a better contextual placeholder to use isstead. * - * For example, a text input field may have a value of null when empty. To - * avoid hiding this field from screen reader, implementations should ensure - * that if the value is null, this function would return an appropriate, - * localized value such as "empty text". + * For example, to avoid hiding an empty text input field from screen reader, + * implementations should ensure that if the text is an empty string, this + * function would return an appropriate, localized value such as "empty text". * * Implementations are responsible for, and encouraged to, return a localized * version of the ARIA representation of the field's value. * - * @returns An ARIA representation of the field's value, or null if no value - * is currently defined or known for the field. + * @returns An ARIA representation of the field's text, or null if no text is + * currently defined or known for the field. */ getAriaValue(): string | null { - const value = this.getValue(); - - if (value === null || value === undefined) { + if (this.getValue() == null) { return null; + } else { + return this.getText(); } - - return String(value); } /** @@ -369,28 +366,24 @@ export abstract class Field * 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 can - * lead to the field being potentially COMPLETELY HIDDEN for screen reader - * navigation so it's crucial for implementations to ensure a non-empty value - * is returned here. + * It's not expected that this method, under normal operations, returns an empty + * string. If the field's value is empty then it will return a localized + * placeholder indicating that its value is empty. * * @param includeTypeInfo Whether to include the field's type information in * the returned label, if available. */ computeAriaLabel(includeTypeInfo: boolean = false): string { const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null; - const ariaValue = this.getAriaValue(); - - if (!ariaTypeName && !ariaValue) { - return ''; + let ariaValue = this.getAriaValue(); + if (ariaValue === null || ariaValue === '') { + ariaValue = Msg['FIELD_LABEL_EMPTY']; } - if (ariaTypeName && ariaValue) { + if (ariaTypeName) { return `${ariaTypeName}: ${ariaValue}`; } - - return ariaTypeName ?? ariaValue ?? ''; + return ariaValue; } /** diff --git a/packages/blockly/core/inputs/input.ts b/packages/blockly/core/inputs/input.ts index ee5f7fdc0..8eb27387c 100644 --- a/packages/blockly/core/inputs/input.ts +++ b/packages/blockly/core/inputs/input.ts @@ -15,6 +15,7 @@ import '../field_label.js'; import type {Block} from '../block.js'; +import {computeFieldRowLabel, getInputLabels} from '../block_aria_composer.js'; import type {BlockSvg} from '../block_svg.js'; import type {Connection} from '../connection.js'; import {ConnectionType} from '../connection_type.js'; @@ -347,4 +348,35 @@ export class Input { // preceding input, since they're all on one row. return inputs[inputIndex - 1].getRowId(); } + + /** + * Returns an accessibility label describing this input, including the labels + * of any fields on the input and the labels of any connected blocks, to help + * disambiguate this input from others on the same block. + * + * @internal + */ + getLabel(): string { + if (!this.isVisible()) return ''; + + const labels = computeFieldRowLabel(this, false); + + if (this.connection?.type === ConnectionType.INPUT_VALUE) { + const childBlock = this.connection.targetBlock(); + if (childBlock && !childBlock.isInsertionMarker()) { + labels.push(getInputLabels(childBlock as BlockSvg).join(' ')); + } + } + return labels.join(' '); + } + + /** + * Returns the index of this input on its source block. + * + * @internal + */ + getIndex(): number { + const inputs = this.getSourceBlock().inputList; + return inputs.indexOf(this); + } } diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 980cd43d4..a1c94aae0 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-09 14:28:47.213464", + "lastupdated": "2026-04-20 12:26:14.946401", "locale": "en", "messagedocumentation" : "qqq" }, @@ -438,5 +438,15 @@ "BLOCK_LABEL_HAS_INPUTS": "has inputs", "BLOCK_LABEL_STATEMENT": "statement", "BLOCK_LABEL_CONTAINER": "container", - "BLOCK_LABEL_VALUE": "value" + "BLOCK_LABEL_VALUE": "value", + "BLOCK_LABEL_STACK_BLOCKS": "%1 stack blocks", + "INPUT_LABEL_INDEX": "input %1", + "ANNOUNCE_MOVE_WORKSPACE": "moving %1 on workspace", + "ANNOUNCE_MOVE_BEFORE": "moving %1 before %3", + "ANNOUNCE_MOVE_AFTER": "moving %1 after %3", + "ANNOUNCE_MOVE_INSIDE": "moving %1 inside %3 %4", + "ANNOUNCE_MOVE_AROUND": "moving %1 %2 around %3", + "ANNOUNCE_MOVE_TO": "moving %1 %2 to %3 %4", + "ANNOUNCE_MOVE_CANCELED": "Canceled movement", + "FIELD_LABEL_EMPTY": "empty" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index e42f1ff5e..0fa1a2cd6 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -445,5 +445,15 @@ "BLOCK_LABEL_HAS_INPUTS": "Part of an accessibility label for a block that indicates that it has more than one input.", "BLOCK_LABEL_STATEMENT": "Part of an accessibility label for a block that indicates that it is a statement block, i.e. that it has a next or previous connection.", "BLOCK_LABEL_CONTAINER": "Part of an accessibility label for a block that indicates that it is a container block, i.e. that it has one or more statement inputs.", - "BLOCK_LABEL_VALUE": "Part of an accessibility label for a block that indicates that it is a value block, i.e. that it has an output connection." + "BLOCK_LABEL_VALUE": "Part of an accessibility label for a block that indicates that it is a value block, i.e. that it has an output connection.", + "BLOCK_LABEL_STACK_BLOCKS": "Accessibility label for a block that indicates it is a stack of two or more blocks.", + "INPUT_LABEL_INDEX": "Accessibility label for an unlabeled input that communicates its index on the block. \n\nParameters:\n* %1 - the index of the input, starting at 1", + "ANNOUNCE_MOVE_WORKSPACE": "ARIA live region message announcing a block is being moved on the workspace, without specifying a target location or specific movement direction.", + "ANNOUNCE_MOVE_BEFORE": "ARIA live region message announcing a block is being moved before another block \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %3 - the label of the target (neighbour) block \n\nExamples:\n* 'moving before repeat 10, times, do'\n* 'moving 2 stack blocks before repeat 10, times, do'", + "ANNOUNCE_MOVE_AFTER": "ARIA live region message announcing a block is being moved after another block \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %3 - the label of the target (neighbour) block \n\nExamples:\n* 'moving after repeat 10, times, do'\n* 'moving 2 stack blocks after repeat 10, times, do' \n* 'moving block A after Function block output'", + "ANNOUNCE_MOVE_INSIDE": "ARIA live region message announcing a block is being moved inside another block, optionally including connection-specific label for disambiguation.", + "ANNOUNCE_MOVE_AROUND": "ARIA live region message announcing a block is being moved around another block, optionally including connection-specific label for disambiguation. \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks \n* %2 - optional phrase describing the local connection label \n* %3 - the label of the target (neighbour) block \n\nExamples:\n* 'moving around print abc'\n* 'moving if, do else statement around print abc'", + "ANNOUNCE_MOVE_TO": "ARIA live region message announcing a block is being moved to a workspace location where the relationship is not specifically known. \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks \n* %2 - optional phrase describing the local connection label \n* %3 - the label of the target (neighbour) block or location \n* %4 - optional phrase describing the target connection label \n\nExamples:\n* 'moving to repeat 10, times, do'\n* 'moving 2 stack blocks else statement to repeat 10, times, do previous connection'", + "ANNOUNCE_MOVE_CANCELED": "ARIA live region message announcing a block movement has been canceled.", + "FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index e9f7dfa9a..9d6162eea 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1770,3 +1770,55 @@ Blockly.Msg.BLOCK_LABEL_CONTAINER = 'container'; /// Part of an accessibility label for a block that indicates that it is /// a value block, i.e. that it has an output connection. Blockly.Msg.BLOCK_LABEL_VALUE = 'value'; +/** @type {string} */ +/// Accessibility label for a block that indicates it is a stack of two or +/// more blocks. +Blockly.Msg.BLOCK_LABEL_STACK_BLOCKS = '%1 stack blocks'; +/** @type {string} */ +/// Accessibility label for an unlabeled input that communicates its index on the block. +/// \n\nParameters:\n* %1 - the index of the input, starting at 1 +Blockly.Msg.INPUT_LABEL_INDEX = 'input %1'; +/** @type {string} */ +/// ARIA live region message announcing a block is being moved on the workspace, without specifying a target location or specific movement direction. +// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks +// \n\nExamples:\n* "moving block A on workspace"\n* "moving 2 stack blocks on workspace" +Blockly.Msg.ANNOUNCE_MOVE_WORKSPACE = 'moving %1 on workspace'; +/** @type {string} */ +/// ARIA live region message announcing a block is being moved before another block +/// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %3 - the label of the target (neighbour) block +/// \n\nExamples:\n* "moving before repeat 10, times, do"\n* "moving 2 stack blocks before repeat 10, times, do" +Blockly.Msg.ANNOUNCE_MOVE_BEFORE = 'moving %1 before %3'; +/** @type {string} */ +/// ARIA live region message announcing a block is being moved after another block +/// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks\n* %3 - the label of the target (neighbour) block +/// \n\nExamples:\n* "moving after repeat 10, times, do"\n* "moving 2 stack blocks after repeat 10, times, do" +/// \n* "moving block A after Function block output" +Blockly.Msg.ANNOUNCE_MOVE_AFTER = 'moving %1 after %3'; +/** @type {string} */ +/// ARIA live region message announcing a block is being moved inside another block, optionally including connection-specific label for disambiguation. +// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks +// \n* %3 - the label of the target (neighbour) block +// \n* %4 - optional phrase describing the target connection label +// \n\nExamples:\n* "moving inside if, do"\n* "moving 2 stack blocks inside if, do else statement" +Blockly.Msg.ANNOUNCE_MOVE_INSIDE = 'moving %1 inside %3 %4'; +/** @type {string} */ +/// ARIA live region message announcing a block is being moved around another block, optionally including connection-specific label for disambiguation. +/// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks +/// \n* %2 - optional phrase describing the local connection label +/// \n* %3 - the label of the target (neighbour) block +/// \n\nExamples:\n* "moving around print abc"\n* "moving if, do else statement around print abc" +Blockly.Msg.ANNOUNCE_MOVE_AROUND = 'moving %1 %2 around %3'; +/** @type {string} */ +/// ARIA live region message announcing a block is being moved to a workspace location where the relationship is not specifically known. +/// \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks +/// \n* %2 - optional phrase describing the local connection label +/// \n* %3 - the label of the target (neighbour) block or location +/// \n* %4 - optional phrase describing the target connection label +/// \n\nExamples:\n* "moving to repeat 10, times, do"\n* "moving 2 stack blocks else statement to repeat 10, times, do previous connection" +Blockly.Msg.ANNOUNCE_MOVE_TO = 'moving %1 %2 to %3 %4'; +/** @type {string} */ +/// ARIA live region message announcing a block movement has been canceled. +Blockly.Msg.ANNOUNCE_MOVE_CANCELED = 'Canceled movement'; +/** @type {string} */ +/// Label for an empty field, used by screen readers to identify fields that have no content. +Blockly.Msg.FIELD_LABEL_EMPTY = 'empty'; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/field_test.js b/packages/blockly/tests/mocha/field_test.js index 7736c6b50..373f44133 100644 --- a/packages/blockly/tests/mocha/field_test.js +++ b/packages/blockly/tests/mocha/field_test.js @@ -873,6 +873,11 @@ suite('Abstract Fields', function () { const field = new TestField(undefined); assert.isNull(field.getAriaValue()); }); + + test('Returns empty string for empty text value', function () { + const field = new TestField(''); + assert.equal(field.getAriaValue(), ''); + }); }); suite('computeAriaLabel', function () { @@ -891,14 +896,36 @@ suite('Abstract Fields', function () { assert.equal(field.computeAriaLabel(true), 'text: hello'); }); - test('Type only when value is null', function () { + test('Type and placeholder when value is null', function () { const field = new TestField(null, {ariaTypeName: 'text'}); - assert.equal(field.computeAriaLabel(true), 'text'); + assert.equal( + field.computeAriaLabel(true), + `text: ${Blockly.Msg['FIELD_LABEL_EMPTY']}`, + ); }); - test('Empty string when no type or value', function () { + test('Placeholder when when value is null and no type', function () { const field = new TestField(null); - assert.equal(field.computeAriaLabel(true), ''); + assert.equal( + field.computeAriaLabel(true), + Blockly.Msg['FIELD_LABEL_EMPTY'], + ); + }); + + test('Placeholder when value is empty string', function () { + const field = new TestField(''); + assert.equal( + field.computeAriaLabel(true), + Blockly.Msg['FIELD_LABEL_EMPTY'], + ); + }); + + test('Type and placeholder when value is empty string', function () { + const field = new TestField('', {ariaTypeName: 'text'}); + assert.equal( + field.computeAriaLabel(true), + `text: ${Blockly.Msg['FIELD_LABEL_EMPTY']}`, + ); }); test('Handles missing type with includeTypeInfo=true', function () { @@ -908,6 +935,20 @@ suite('Abstract Fields', function () { }); suite('Subclass overrides', function () { + test('Override returning empty string still results in placeholder', function () { + class EmptyOverrideField extends TestField { + getAriaValue() { + return ''; + } + } + + const field = new EmptyOverrideField(); + assert.equal( + field.computeAriaLabel(), + Blockly.Msg['FIELD_LABEL_EMPTY'], + ); + }); + class CustomValueField extends TestField { getAriaValue() { return 'custom value'; diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index 115275e63..11b7e929b 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {getInputLabelsSubset} from '../../build/src/core/block_aria_composer.js'; import * as Blockly from '../../build/src/core/blockly.js'; import {assert} from '../../node_modules/chai/index.js'; import { @@ -939,6 +940,232 @@ suite('Keyboard-driven movement', function () { } }); }); + + suite('Announcement tests', function () { + setup(function () { + this.workspace.clear(); + this.liveRegion = document.getElementById('blocklyAriaAnnounce'); + this.moveAndAssert = (moveFn, incPhrases, exclPhrases = []) => { + moveFn(this.workspace); + this.clock.tick(11); + let text = this.liveRegion.textContent; + exclPhrases.forEach((unexpected) => { + assert.notInclude(text, unexpected); + }); + incPhrases.forEach((expected) => { + assert.include(text, expected); + const index = text.indexOf(expected); + text = + text.slice(0, index) + + text.slice(index + expected.toString().length); + }); + }; + this.getBlockLabel = (block) => + block.getAriaLabel(Blockly.utils.aria.Verbosity.TERSE); + this.block1 = this.workspace.newBlock('draw_emoji'); + this.block1.initSvg(); + this.block1.render(); + }); + + test('announces simple block moving on workspace', function () { + Blockly.getFocusManager().focusNode(this.block1); + this.moveAndAssert( + startMove, + ['moving', this.getBlockLabel(this.block1), 'on workspace'], + [], + ); + cancelMove(this.workspace); + }); + + test('announces stack count when moving stack', function () { + const block2 = this.workspace.newBlock('draw_emoji'); + block2.setFieldValue('✨', 'emoji'); + block2.initSvg(); + block2.render(); + this.block1.nextConnection.connect(block2.previousConnection); + + Blockly.getFocusManager().focusNode(this.block1); + this.moveAndAssert(startMoveStack, [ + 'moving', + '2 stack blocks', + 'on workspace', + ]); + cancelMove(this.workspace); + }); + + test('announces "before" when moving before a block', function () { + const block2 = this.workspace.newBlock('draw_emoji'); + block2.setFieldValue('✨', 'emoji'); + block2.initSvg(); + block2.render(); + + Blockly.getFocusManager().focusNode(this.block1); + startMove(this.workspace); + + this.moveAndAssert( + moveRight, + ['moving', 'before', this.getBlockLabel(block2)], + [this.getBlockLabel(this.block1)], + ); + + cancelMove(this.workspace); + }); + test('announces "after" when moving after a block', function () { + const block2 = this.workspace.newBlock('draw_emoji'); + block2.setFieldValue('✨', 'emoji'); + block2.initSvg(); + block2.render(); + + this.block1.nextConnection.connect(block2.previousConnection); + + Blockly.getFocusManager().focusNode(block2); + + this.moveAndAssert(startMove, [ + 'moving', + this.getBlockLabel(block2), + 'after', + this.getBlockLabel(this.block1), + ]); + + cancelMove(this.workspace); + }); + test('announces "inside" for value connections', function () { + const valueBlock = this.workspace.newBlock('text'); + valueBlock.initSvg(); + valueBlock.render(); + + const parent = this.workspace.newBlock('text_print'); + parent.initSvg(); + parent.render(); + + Blockly.getFocusManager().focusNode(valueBlock); + startMove(this.workspace); + + this.moveAndAssert( + moveRight, + ['moving', 'inside', this.getBlockLabel(parent)], + [this.getBlockLabel(valueBlock)], + ); + + cancelMove(this.workspace); + }); + test('announces "around" when wrapping a block', function () { + const loop = this.workspace.newBlock('controls_repeat_ext'); + loop.initSvg(); + loop.render(); + + Blockly.getFocusManager().focusNode(loop); + startMove(this.workspace); + moveRight(this.workspace); + + this.moveAndAssert( + moveRight, + ['moving', 'around', this.getBlockLabel(this.block1)], + [ + this.getBlockLabel(loop), + getInputLabelsSubset(loop, loop.getInput('DO')).join(', '), + ], + ); + + cancelMove(this.workspace); + }); + test('disambiguates between multiple statement inputs', function () { + const ifBlock = this.workspace.newBlock('controls_if'); + ifBlock.initSvg(); + ifBlock.elseifCount_ = 1; + ifBlock.elseCount_ = 1; + ifBlock.updateShape_(); + ifBlock.render(); + this.workspace.cleanUp(); + + Blockly.getFocusManager().focusNode(ifBlock); + startMove(this.workspace); // on workspace + moveRight(this.workspace); // before block1 + this.moveAndAssert( + moveRight, + [ + 'moving', + getInputLabelsSubset(ifBlock, ifBlock.getInput('DO1')).join(', '), + 'around', + this.getBlockLabel(this.block1), + ], + [this.getBlockLabel(ifBlock)], + ); + this.moveAndAssert( + moveRight, + [ + 'moving', + getInputLabelsSubset(ifBlock, ifBlock.getInput('DO0')).join(', '), + 'around', + this.getBlockLabel(this.block1), + ], + [this.getBlockLabel(ifBlock)], + ); + + cancelMove(this.workspace); + }); + test('disambiguates between multiple value inputs', function () { + const compare = this.workspace.newBlock('logic_compare'); + compare.initSvg(); + compare.render(); + const boolean = this.workspace.newBlock('logic_boolean'); + boolean.initSvg(); + boolean.render(); + + Blockly.getFocusManager().focusNode(boolean); + startMove(this.workspace); + + this.moveAndAssert( + moveRight, + [ + 'moving', + 'inside', + this.getBlockLabel(compare), + getInputLabelsSubset(compare, compare.getInput('A')).join(', '), + ], + [this.getBlockLabel(boolean)], + ); + this.moveAndAssert( + moveRight, + [ + 'moving', + 'inside', + this.getBlockLabel(compare), + getInputLabelsSubset(compare, compare.getInput('B')).join(', '), + ], + [this.getBlockLabel(boolean)], + ); + + cancelMove(this.workspace); + }); + test('disambiguates between unlabeled value inputs', function () { + const textJoin = this.workspace.newBlock('text_join'); + textJoin.itemCount_ = 3; + textJoin.updateShape_(); + textJoin.initSvg(); + textJoin.render(); + const text = this.workspace.newBlock('text'); + text.initSvg(); + text.render(); + + Blockly.getFocusManager().focusNode(text); + startMove(this.workspace); + moveRight(this.workspace); // First labeled input + + this.moveAndAssert( + moveRight, + ['moving', 'inside', this.getBlockLabel(textJoin), 'input 2'], + [this.getBlockLabel(text)], + ); + this.moveAndAssert( + moveRight, + ['moving', 'inside', this.getBlockLabel(textJoin), 'input 3'], + [this.getBlockLabel(text)], + ); + + cancelMove(this.workspace); + }); + }); }); suite('of bubbles', function () { From ca81c9ad0551f437f816e2efccd49a14468d0e4e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 20 Apr 2026 12:47:30 -0700 Subject: [PATCH 054/200] fix: Improve navigation and movement looping behavior (#9732) * fix: Default navigation looping to on * fix: Don't show the unconstrained move hint every time a block is moved to the workspace * fix: Normalize block movement during drags * fix: Offset proposed top-level blocks during constrained drags * fix: Make constrained moves respect the navigator's looping setting * fix: Fix tests * chore: Fix docstring * fix: Show unconstrained move hint only when there are no available connections * refactor: Use constants --- .../core/dragging/block_drag_strategy.ts | 142 ++++++++++++++++-- .../core/keyboard_nav/navigators/navigator.ts | 2 +- .../tests/mocha/keyboard_navigation_test.js | 66 ++++++++ .../tests/mocha/shortcut_items_test.js | 46 ++++-- packages/blockly/tests/mocha/toolbox_test.js | 2 + 5 files changed, 229 insertions(+), 29 deletions(-) diff --git a/packages/blockly/core/dragging/block_drag_strategy.ts b/packages/blockly/core/dragging/block_drag_strategy.ts index fc09eb8eb..bca926d47 100644 --- a/packages/blockly/core/dragging/block_drag_strategy.ts +++ b/packages/blockly/core/dragging/block_drag_strategy.ts @@ -120,7 +120,7 @@ export class BlockDragStrategy implements IDragStrategy { newBlock.workspace, screenCoordinate, ); - newBlock.moveTo(workspaceCoordinates); + newBlock.moveDuringDrag(workspaceCoordinates); } /** @@ -511,8 +511,11 @@ export class BlockDragStrategy implements IDragStrategy { // No connection was available or adequately close to the dragged block; // suggest using unconstrained mode to arbitrarily position the block if // we're in keyboard-driven constrained mode. - if (this.moveMode === MoveMode.CONSTRAINED) { - showUnconstrainedMoveHint(this.workspace, true); + if ( + this.moveMode === MoveMode.CONSTRAINED && + !this.allConnectionPairs.length + ) { + showUnconstrainedMoveHint(this.workspace); this.workspace.getAudioManager().playErrorBeep(); } } @@ -536,6 +539,41 @@ export class BlockDragStrategy implements IDragStrategy { const newCandidate = this.getConnectionCandidate(delta); if (!newCandidate) { + // Position above or below the first/last block. + const connectedBlock = currCandidate?.neighbour.getSourceBlock(); + let root = connectedBlock?.getRootBlock() ?? connectedBlock; + if (root === draggingBlock) root = connectedBlock; + const direction = this.getDirectionToNewLocation( + Coordinate.sum(this.startLoc!, delta), + ); + const bounds = root?.getBoundingRectangle(); + if (!bounds) return; + + let destination: Coordinate; + switch (direction) { + case Direction.LEFT: + case Direction.UP: + destination = new Coordinate( + bounds.getOrigin().x, + bounds.getOrigin().y - + this.BLOCK_CONNECTION_OFFSET * 2 - + draggingBlock.getHeightWidth().height, + ); + break; + case Direction.RIGHT: + case Direction.DOWN: + default: + destination = new Coordinate( + bounds.getOrigin().x, + bounds.getOrigin().y + + bounds.getHeight() + + this.BLOCK_CONNECTION_OFFSET * 2, + ); + break; + } + + draggingBlock.moveDuringDrag(destination); + this.connectionPreviewer?.hidePreview(); this.connectionCandidate = null; return; @@ -627,15 +665,23 @@ export class BlockDragStrategy implements IDragStrategy { delta: Coordinate, ): ConnectionCandidate | null { if (this.moveMode === MoveMode.CONSTRAINED) { - const direction = this.getDirectionToNewLocation( - Coordinate.sum(this.startLoc!, delta), - ); - return this.findTraversalCandidate(direction); + return this.findTraversalCandidate(delta); } // If we do not have a candidate yet, we fallback to the closest one nearby. + return this.getClosestCandidate(this.block, delta); + } + + /** + * Returns the closest connection candidate for the given block. + * + * @param block The block to find a connection for. + * @param delta The distance the block has traveled since dragging began. + * @returns The closest available connection candidate, if any. + */ + private getClosestCandidate(block: BlockSvg, delta: Coordinate) { let radius = this.getSearchRadius(); - const localConns = this.getLocalConnections(this.block); + const localConns = this.getLocalConnections(block); let candidate: ConnectionCandidate | null = null; for (const conn of localConns) { @@ -800,7 +846,7 @@ export class BlockDragStrategy implements IDragStrategy { } } } else { - this.block.moveTo(this.startLoc!, ['drag']); + this.block.moveDuringDrag(this.startLoc!); this.workspace .getLayerManager() ?.moveOffDragLayer(this.block, layers.BLOCK); @@ -830,10 +876,13 @@ export class BlockDragStrategy implements IDragStrategy { /** * Get the nearest valid candidate connection in traversal order. * - * @param direction The cardinal direction in which the block is being moved. + * @param delta The distance the block has moved since this drag began. * @returns A candidate connection and radius, or null if none was found. */ - findTraversalCandidate(direction: Direction): ConnectionCandidate | null { + findTraversalCandidate(delta: Coordinate): ConnectionCandidate | null { + const direction = this.getDirectionToNewLocation( + Coordinate.sum(this.startLoc!, delta), + ); const pairs = this.allConnectionPairs; if (direction === Direction.NONE || !pairs.length) { return this.connectionCandidate; @@ -846,9 +895,16 @@ export class BlockDragStrategy implements IDragStrategy { this.connectionCandidate?.neighbour === pair.neighbour, ); + const navigator = this.block.workspace.getNavigator(); if (forwardTraversal) { if (currentPairIndex === -1) { - return this.pairToCandidate(pairs[0]); + const terminal = this.isInTerminalPosition(this.block, Direction.DOWN); + if (navigator.getNavigationLoops()) { + return this.pairToCandidate(pairs[0]); + } else if (!terminal) { + return this.getClosestCandidate(this.block, delta); + } + return null; } else if (currentPairIndex === pairs.length - 1) { return null; } else { @@ -856,7 +912,13 @@ export class BlockDragStrategy implements IDragStrategy { } } else { if (currentPairIndex === -1) { - return this.pairToCandidate(pairs[pairs.length - 1]); + const terminal = this.isInTerminalPosition(this.block, Direction.UP); + if (navigator.getNavigationLoops()) { + return this.pairToCandidate(pairs[pairs.length - 1]); + } else if (!terminal) { + return this.getClosestCandidate(this.block, delta); + } + return null; } else if (currentPairIndex === 0) { return null; } else { @@ -865,9 +927,63 @@ export class BlockDragStrategy implements IDragStrategy { } } + /** + * Returns whether or not the given block is at a terminal position (start or + * end) of the blocks on the workspace. This helps distinguish between a block + * that is at the end of the line because all valid connections have been + * visited and the proposed constrained move destination is now to drop it on + * the workspace as a top-level block (in which case it will be in a terminal + * position), and a block that just entered move mode as a top-level block, + * and should therefore still be able to move to another connection point + * even if looping is disabled. + * + * @param block The block to check. + * @param direction The current dragging direction. + * @returns True if the block is at the start or end of its possible positions + * on the workspace. + */ + private isInTerminalPosition( + block: BlockSvg, + direction: Direction.UP | Direction.DOWN, + ) { + if (block.getParent()) { + return false; + } + + const topBlocks = block.workspace.getTopBlocks(true); + + const index = topBlocks.indexOf(block); + const delta = direction === Direction.UP ? -1 : 1; + const start = index + delta; + + // Generally terminal blocks will be at the start or end of the sorted list + // of top blocks, but it still counts if all of the blocks before/after it + // have no valid connection points for the block in question. + const blockConnections = block.getConnections_(false); + for (let i = start; i >= 0 && i < topBlocks.length; i += delta) { + const topBlock = topBlocks[i]; + for (const a of blockConnections) { + for (const b of topBlock.getConnections_(false)) { + if ( + block.workspace.connectionChecker.canConnect(a, b, true, Infinity) + ) { + return false; + } + } + } + } + + return true; + } + + /** + * Converts a connection pair to a connection candidate with a default + * distance of 0. + */ private pairToCandidate(pair: ConnectionPair): ConnectionCandidate { return {...pair, distance: 0}; } + /** * Returns the cardinal direction that the block being dragged would have to * move in to reach the given location. diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index 8621ed1e2..efe01e9dd 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -56,7 +56,7 @@ export class Navigator { ]; /** Whether or not navigation loops around when reaching the end. */ - protected navigationLoops = false; + protected navigationLoops = true; /** * Adds a navigation ruleset to this Navigator. diff --git a/packages/blockly/tests/mocha/keyboard_navigation_test.js b/packages/blockly/tests/mocha/keyboard_navigation_test.js index 58fb3d935..1ff150b26 100644 --- a/packages/blockly/tests/mocha/keyboard_navigation_test.js +++ b/packages/blockly/tests/mocha/keyboard_navigation_test.js @@ -561,6 +561,7 @@ suite('Toolbox and flyout arrow navigation by layout', function () { }); test('Previous toolbox item from first is no-op', function () { + this.workspace.getToolbox().getNavigator().setNavigationLoops(false); Blockly.getFocusManager().focusNode(this.firstToolboxItem); pressKey(this.workspace, this.keys.previousItem); assert.equal( @@ -569,6 +570,16 @@ suite('Toolbox and flyout arrow navigation by layout', function () { ); }); + test('Previous toolbox item from first loops to last', function () { + this.workspace.getToolbox().getNavigator().setNavigationLoops(true); + Blockly.getFocusManager().focusNode(this.firstToolboxItem); + pressKey(this.workspace, this.keys.previousItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.lastToolboxItem, + ); + }); + test('Previous toolbox item', function () { Blockly.getFocusManager().focusNode(this.lastToolboxItem); pressKey(this.workspace, this.keys.previousItem); @@ -579,6 +590,7 @@ suite('Toolbox and flyout arrow navigation by layout', function () { }); test('Next toolbox item from last is no-op', function () { + this.workspace.getToolbox().getNavigator().setNavigationLoops(false); Blockly.getFocusManager().focusNode(this.lastToolboxItem); pressKey(this.workspace, this.keys.nextItem); assert.equal( @@ -587,6 +599,16 @@ suite('Toolbox and flyout arrow navigation by layout', function () { ); }); + test('Next toolbox item from last loops', function () { + this.workspace.getToolbox().getNavigator().setNavigationLoops(true); + Blockly.getFocusManager().focusNode(this.lastToolboxItem); + pressKey(this.workspace, this.keys.nextItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.firstToolboxItem, + ); + }); + test('Next toolbox item', function () { Blockly.getFocusManager().focusNode(this.firstToolboxItem); pressKey(this.workspace, this.keys.nextItem); @@ -615,6 +637,11 @@ suite('Toolbox and flyout arrow navigation by layout', function () { }); test('Previous flyout item from first is no-op', function () { + this.workspace + .getFlyout() + .getWorkspace() + .getNavigator() + .setNavigationLoops(false); pressKey(this.workspace, Blockly.utils.KeyCodes.T); Blockly.getFocusManager().focusNode( this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], @@ -626,6 +653,23 @@ suite('Toolbox and flyout arrow navigation by layout', function () { ); }); + test('Previous flyout item from first loops', function () { + this.workspace + .getFlyout() + .getWorkspace() + .getNavigator() + .setNavigationLoops(true); + pressKey(this.workspace, Blockly.utils.KeyCodes.T); + Blockly.getFocusManager().focusNode( + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + pressKey(this.workspace, this.keys.previousItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getFlyout().getWorkspace().getTopBlocks()[1], + ); + }); + test('Previous flyout item', function () { pressKey(this.workspace, Blockly.utils.KeyCodes.T); Blockly.getFocusManager().focusNode( @@ -639,6 +683,11 @@ suite('Toolbox and flyout arrow navigation by layout', function () { }); test('Next flyout item from last is no-op', function () { + this.workspace + .getFlyout() + .getWorkspace() + .getNavigator() + .setNavigationLoops(false); pressKey(this.workspace, Blockly.utils.KeyCodes.T); Blockly.getFocusManager().focusNode( this.workspace.getFlyout().getWorkspace().getTopBlocks()[1], @@ -650,6 +699,23 @@ suite('Toolbox and flyout arrow navigation by layout', function () { ); }); + test('Next flyout item from last loops', function () { + this.workspace + .getFlyout() + .getWorkspace() + .getNavigator() + .setNavigationLoops(true); + pressKey(this.workspace, Blockly.utils.KeyCodes.T); + Blockly.getFocusManager().focusNode( + this.workspace.getFlyout().getWorkspace().getTopBlocks()[1], + ); + pressKey(this.workspace, this.keys.nextItem); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace.getFlyout().getWorkspace().getTopBlocks()[0], + ); + }); + test('Next flyout item', function () { pressKey(this.workspace, Blockly.utils.KeyCodes.T); Blockly.getFocusManager().focusNode( diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 3b2154eb3..7b92b534c 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -39,8 +39,10 @@ suite('Keyboard Shortcut Items', function () { */ function setSelectedBlock(workspace) { const block = workspace.newBlock('stack_block'); + block.initSvg(); + block.render(); Blockly.common.setSelected(block); - sinon.stub(Blockly.getFocusManager(), 'getFocusedNode').returns(block); + Blockly.getFocusManager().focusNode(block); return block; } @@ -146,9 +148,6 @@ suite('Keyboard Shortcut Items', function () { }); // Do not delete anything if a connection is focused. test('Not called when connection is focused', function () { - // Restore the stub behavior called during setup - Blockly.getFocusManager().getFocusedNode.restore(); - setSelectedConnection(this.workspace); const event = createKeyDownEvent(Blockly.utils.KeyCodes.DELETE); this.injectionDiv.dispatchEvent(event); @@ -203,9 +202,6 @@ suite('Keyboard Shortcut Items', function () { sinon.assert.notCalled(this.hideChaffSpy); }); test('Not called when connection is focused', function () { - // Restore the stub behavior called during setup - Blockly.getFocusManager().getFocusedNode.restore(); - setSelectedConnection(this.workspace); const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [ Blockly.utils.KeyCodes.CTRL, @@ -216,7 +212,6 @@ suite('Keyboard Shortcut Items', function () { }); // Copy a comment. test('Workspace comment', function () { - Blockly.getFocusManager().getFocusedNode.restore(); this.comment = setSelectedComment(this.workspace); this.copySpy = sinon.spy(this.comment, 'toCopyData'); @@ -279,9 +274,6 @@ suite('Keyboard Shortcut Items', function () { sinon.assert.notCalled(this.hideChaffSpy); }); test('Not called when connection is focused', function () { - // Restore the stub behavior called during setup - Blockly.getFocusManager().getFocusedNode.restore(); - setSelectedConnection(this.workspace); const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [ Blockly.utils.KeyCodes.CTRL, @@ -294,7 +286,6 @@ suite('Keyboard Shortcut Items', function () { // Cut a comment. test('Workspace comment', function () { - Blockly.getFocusManager().getFocusedNode.restore(); this.comment = setSelectedComment(this.workspace); this.copySpy = sinon.spy(this.comment, 'toCopyData'); this.disposeSpy = sinon.spy(this.comment, 'dispose'); @@ -435,7 +426,7 @@ suite('Keyboard Shortcut Items', function () { contextMenuKeyEvent, ); for (const option of menuOptions) { - assert.include(menu.getElement().innerText, option.text); + assert.include(menu.getElement().textContent, option.text); } }); @@ -451,7 +442,7 @@ suite('Keyboard Shortcut Items', function () { contextMenuKeyEvent, ); for (const option of menuOptions) { - assert.include(menu.getElement().innerText, option.text); + assert.include(menu.getElement().textContent, option.text); } }); @@ -468,7 +459,7 @@ suite('Keyboard Shortcut Items', function () { contextMenuKeyEvent, ); for (const option of menuOptions) { - assert.include(menu.getElement().innerText, option.text); + assert.include(menu.getElement().textContent, option.text); } }); @@ -888,6 +879,7 @@ suite('Keyboard Shortcut Items', function () { }); test('First stack navigating back is a no-op', function () { + this.workspace.getNavigator().setNavigationLoops(false); Blockly.getFocusManager().focusNode(this.block1); this.injectionDiv.dispatchEvent(keyPrevStack()); assert.strictEqual( @@ -896,7 +888,18 @@ suite('Keyboard Shortcut Items', function () { ); }); + test('First stack navigating back loops', function () { + this.workspace.getNavigator().setNavigationLoops(true); + Blockly.getFocusManager().focusNode(this.block1); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block3, + ); + }); + test('Last stack navigating forward is a no-op', function () { + this.workspace.getNavigator().setNavigationLoops(false); Blockly.getFocusManager().focusNode(this.block3); this.injectionDiv.dispatchEvent(keyNextStack()); assert.strictEqual( @@ -905,6 +908,16 @@ suite('Keyboard Shortcut Items', function () { ); }); + test('Last stack navigating forward loops', function () { + this.workspace.getNavigator().setNavigationLoops(true); + Blockly.getFocusManager().focusNode(this.block3); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block1, + ); + }); + test('Block forward to block', function () { Blockly.getFocusManager().focusNode(this.block1); this.injectionDiv.dispatchEvent(keyNextStack()); @@ -1096,9 +1109,12 @@ suite('Keyboard Shortcut Items', function () { .getFirstChild(this.workspace.getFlyout().getWorkspace()); assert.instanceOf(block, Blockly.BlockSvg); Blockly.getFocusManager().focusNode(block); + first.moveTo(new Blockly.utils.Coordinate(500, 500)); const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER); this.workspace.getInjectionDiv().dispatchEvent(event); + const event2 = createKeyDownEvent(Blockly.utils.KeyCodes.UP); + this.workspace.getInjectionDiv().dispatchEvent(event2); const movingBlock = Blockly.getFocusManager().getFocusedNode(); assert.notEqual(block, movingBlock); diff --git a/packages/blockly/tests/mocha/toolbox_test.js b/packages/blockly/tests/mocha/toolbox_test.js index 4b1af1427..5886fe7f6 100644 --- a/packages/blockly/tests/mocha/toolbox_test.js +++ b/packages/blockly/tests/mocha/toolbox_test.js @@ -301,6 +301,7 @@ suite('Toolbox', function () { }); test('Down arrow on last item should be a no-op', function () { + this.toolbox.getNavigator().setNavigationLoops(false); const items = this.toolbox.getToolboxItems(); Blockly.getFocusManager().focusNode(items[6]); const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); @@ -367,6 +368,7 @@ suite('Toolbox', function () { }); test('Up arrow on first item should be a no-op', function () { + this.toolbox.getNavigator().setNavigationLoops(false); const items = this.toolbox.getToolboxItems(); Blockly.getFocusManager().focusNode(items[0]); const oldIndex = items.indexOf(this.toolbox.getSelectedItem()); From 80ee2d5860586763ed8fa7f9d2614dbd9b3bc6a7 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 20 Apr 2026 13:44:17 -0700 Subject: [PATCH 055/200] fix!: Improve keyboard navigation of icons and bubbles (#9737) * fix!: Improve keyboard navigation of icons and bubbles * chore: Fix docstrings * chore: Remove debugging --- packages/blockly/core/blockly.ts | 2 +- .../core/bubbles/mini_workspace_bubble.ts | 5 +- packages/blockly/core/bubbles/text_bubble.ts | 5 +- packages/blockly/core/css.ts | 2 +- packages/blockly/core/icons/mutator_icon.ts | 1 + packages/blockly/core/icons/warning_icon.ts | 1 + .../block_comment_navigation_policy.ts | 86 ------------- .../block_navigation_policy.ts | 17 ++- .../bubble_navigation_policy.ts | 101 ++++++++++++++++ .../icon_navigation_policy.ts | 21 +--- .../core/keyboard_nav/navigators/navigator.ts | 4 +- .../tests/mocha/keyboard_navigation_test.js | 113 ++++++++++++++++++ 12 files changed, 245 insertions(+), 113 deletions(-) delete mode 100644 packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts create mode 100644 packages/blockly/core/keyboard_nav/navigation_policies/bubble_navigation_policy.ts diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 3e28908d1..6cff67e8b 100644 --- a/packages/blockly/core/blockly.ts +++ b/packages/blockly/core/blockly.ts @@ -438,8 +438,8 @@ Names.prototype.populateProcedures = function ( // clang-format on export * from './interfaces/i_navigation_policy.js'; -export * from './keyboard_nav/navigation_policies/block_comment_navigation_policy.js'; export * from './keyboard_nav/navigation_policies/block_navigation_policy.js'; +export * from './keyboard_nav/navigation_policies/bubble_navigation_policy.js'; export * from './keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.js'; export * from './keyboard_nav/navigation_policies/comment_editor_navigation_policy.js'; export * from './keyboard_nav/navigation_policies/connection_navigation_policy.js'; diff --git a/packages/blockly/core/bubbles/mini_workspace_bubble.ts b/packages/blockly/core/bubbles/mini_workspace_bubble.ts index 956787de4..405851796 100644 --- a/packages/blockly/core/bubbles/mini_workspace_bubble.ts +++ b/packages/blockly/core/bubbles/mini_workspace_bubble.ts @@ -7,6 +7,8 @@ import type {BlocklyOptions} from '../blockly_options.js'; import {Abstract as AbstractEvent} from '../events/events_abstract.js'; import {getFocusManager} from '../focus_manager.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import {KeyboardMover} from '../keyboard_nav/keyboard_mover.js'; import {Options} from '../options.js'; import {Coordinate} from '../utils/coordinate.js'; @@ -52,8 +54,9 @@ export class MiniWorkspaceBubble extends Bubble { public readonly workspace: WorkspaceSvg, protected anchor: Coordinate, protected ownerRect?: Rect, + protected owner?: IHasBubble & IFocusableNode, ) { - super(workspace, anchor, ownerRect); + super(workspace, anchor, ownerRect, undefined, owner); const options = new Options(workspaceOptions); this.validateWorkspaceOptions(options); diff --git a/packages/blockly/core/bubbles/text_bubble.ts b/packages/blockly/core/bubbles/text_bubble.ts index 99299fa50..469cd909a 100644 --- a/packages/blockly/core/bubbles/text_bubble.ts +++ b/packages/blockly/core/bubbles/text_bubble.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; @@ -23,8 +25,9 @@ export class TextBubble extends Bubble { public readonly workspace: WorkspaceSvg, protected anchor: Coordinate, protected ownerRect?: Rect, + protected owner?: IHasBubble & IFocusableNode, ) { - super(workspace, anchor, ownerRect); + super(workspace, anchor, ownerRect, undefined, owner); this.paragraph = this.stringToSvg(text, this.contentContainer); this.updateBubbleSize(); dom.addClass(this.svgRoot, 'blocklyTextBubble'); diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index beeded18b..73aa8b652 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -644,7 +644,7 @@ input[type=number] { /* The workspace itself is the active node. */ .blocklyKeyboardNavigation .blocklyBubble.blocklyActiveFocus - .blocklyDraggable { + .blocklyEmboss .blocklyDraggable { stroke: var(--blockly-active-node-color); stroke-width: var(--blockly-selection-width); } diff --git a/packages/blockly/core/icons/mutator_icon.ts b/packages/blockly/core/icons/mutator_icon.ts index 9055a91ea..7d001def9 100644 --- a/packages/blockly/core/icons/mutator_icon.ts +++ b/packages/blockly/core/icons/mutator_icon.ts @@ -176,6 +176,7 @@ export class MutatorIcon extends Icon implements IHasBubble { this.sourceBlock.workspace, this.getAnchorLocation(), this.getBubbleOwnerRect(), + this, ); this.applyColour(); this.createRootBlock(); diff --git a/packages/blockly/core/icons/warning_icon.ts b/packages/blockly/core/icons/warning_icon.ts index f24a6a561..2e00c10d7 100644 --- a/packages/blockly/core/icons/warning_icon.ts +++ b/packages/blockly/core/icons/warning_icon.ts @@ -182,6 +182,7 @@ export class WarningIcon extends Icon implements IHasBubble { this.sourceBlock.workspace, this.getAnchorLocation(), this.getBubbleOwnerRect(), + this, ); this.applyColour(); } else { diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts deleted file mode 100644 index 36b15db24..000000000 --- a/packages/blockly/core/keyboard_nav/navigation_policies/block_comment_navigation_policy.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {TextInputBubble} from '../../bubbles/textinput_bubble.js'; -import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; -import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; - -/** - * Set of rules controlling keyboard navigation from an TextInputBubble. - */ -export class BlockCommentNavigationPolicy - implements INavigationPolicy -{ - /** - * Returns the first child of the given block comment. - * - * @param current The block comment to return the first child of. - * @returns The text editor of the given block comment bubble. - */ - getFirstChild(current: TextInputBubble): IFocusableNode | null { - return current.getEditor(); - } - - /** - * Returns the parent of the given block comment. - * - * @param current The block comment to return the parent of. - * @returns The parent block of the given block comment. - */ - getParent(current: TextInputBubble): IFocusableNode | null { - return current.getOwner() ?? null; - } - - /** - * Returns the next peer node of the given block comment. - * - * @param _current The block comment to find the following element of. - * @returns Null. - */ - getNextSibling(_current: TextInputBubble): IFocusableNode | null { - return null; - } - - /** - * Returns the previous peer node of the given block comment. - * - * @param _current The block comment to find the preceding element of. - * @returns Null. - */ - getPreviousSibling(_current: TextInputBubble): IFocusableNode | null { - return null; - } - - /** - * Returns the row ID of the given block comment. - * - * @param current The block comment to retrieve the row ID of. - * @returns The row ID of the given block comment. - */ - getRowId(current: TextInputBubble) { - return current.id; - } - - /** - * Returns whether or not the given block comment can be navigated to. - * - * @param current The instance to check for navigability. - * @returns True if the given block comment can be focused. - */ - isNavigable(current: TextInputBubble): boolean { - return current.canBeFocused(); - } - - /** - * Returns whether the given object can be navigated from by this policy. - * - * @param current The object to check if this policy applies to. - * @returns True if the object is an TextInputBubble. - */ - isApplicable(current: any): current is TextInputBubble { - return current instanceof TextInputBubble; - } -} diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts index 183c92e7b..bd144f74d 100644 --- a/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/block_navigation_policy.ts @@ -6,6 +6,7 @@ import {BlockSvg} from '../../block_svg.js'; import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import {hasBubble} from '../../interfaces/i_has_bubble.js'; import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {RenderedConnection} from '../../rendered_connection.js'; @@ -121,8 +122,20 @@ function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] { // Collapsed blocks have no navigable children. if (block.isCollapsed()) return []; - // Icons are navigable. - const candidates: IFocusableNode[] = block.getIcons(); + const candidates: IFocusableNode[] = []; + + // Icons and open bubbles are navigable. + for (const icon of block.getIcons()) { + candidates.push(icon); + let bubble; + if ( + hasBubble(icon) && + icon.bubbleIsVisible() && + (bubble = icon.getBubble()) + ) { + candidates.push(bubble); + } + } for (const input of block.inputList) { // Invisible inputs are not valid navigation candidates. diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/bubble_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/bubble_navigation_policy.ts new file mode 100644 index 000000000..db091cca3 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigation_policies/bubble_navigation_policy.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../../block_svg.js'; +import {Bubble} from '../../bubbles/bubble.js'; +import type {Icon} from '../../icons/icon.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import {navigateBlock} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a Bubble. + */ +export class BubbleNavigationPolicy implements INavigationPolicy { + /** + * Returns the first child of the given bubble. + * + * @param _current The bubble to return the first child of. + * @returns Null. + */ + getFirstChild(_current: Bubble): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given bubble. + * + * @param current The bubble to return the parent of. + * @returns The parent block of the given bubble. + */ + getParent(current: Bubble): IFocusableNode | null { + return current.getOwner() ?? null; + } + + /** + * Returns the next peer node of the given bubble. + * + * @param current The bubble to find the following element of. + * @returns The next navigable item on the bubble's icon's parent block. + */ + getNextSibling(current: Bubble): IFocusableNode | null { + return navigateBlock( + (current.getOwner() as Icon | undefined)?.getSourceBlock() as BlockSvg, + current, + 1, + ); + } + + /** + * Returns the previous peer node of the given bubble. + * + * @param current The bubble to find the preceding element of. + * @returns The previous navigable item on the bubble's icon's parent block. + */ + getPreviousSibling(current: Bubble): IFocusableNode | null { + return navigateBlock( + (current.getOwner() as Icon | undefined)?.getSourceBlock() as BlockSvg, + current, + -1, + ); + } + + /** + * Returns the row ID of the given bubble. + * + * @param current The bubble to retrieve the row ID of. + * @returns The row ID of the given bubble. + */ + getRowId(current: Bubble) { + return ( + ( + (current.getOwner() as Icon | undefined)?.getSourceBlock() as + | BlockSvg + | undefined + )?.getRowId() ?? '' + ); + } + + /** + * Returns whether or not the given bubble can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given bubble can be focused. + */ + isNavigable(current: Bubble): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an Bubble. + */ + isApplicable(current: any): current is Bubble { + return current instanceof Bubble; + } +} diff --git a/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts b/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts index d0d2b5ad1..b2d080a6c 100644 --- a/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts +++ b/packages/blockly/core/keyboard_nav/navigation_policies/icon_navigation_policy.ts @@ -5,10 +5,7 @@ */ import {BlockSvg} from '../../block_svg.js'; -import {getFocusManager} from '../../focus_manager.js'; -import {CommentIcon} from '../../icons/comment_icon.js'; import {Icon} from '../../icons/icon.js'; -import {MutatorIcon} from '../../icons/mutator_icon.js'; import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {navigateBlock} from './block_navigation_policy.js'; @@ -20,24 +17,10 @@ export class IconNavigationPolicy implements INavigationPolicy { /** * Returns the first child of the given icon. * - * @param current The icon to return the first child of. + * @param _current The icon to return the first child of. * @returns Null. */ - getFirstChild(current: Icon): IFocusableNode | null { - if ( - current instanceof MutatorIcon && - current.bubbleIsVisible() && - getFocusManager().getFocusedNode() === current - ) { - return current.getBubble()?.getWorkspace() ?? null; - } else if ( - current instanceof CommentIcon && - current.bubbleIsVisible() && - getFocusManager().getFocusedNode() === current - ) { - return current.getBubble()?.getEditor() ?? null; - } - + getFirstChild(_current: Icon): IFocusableNode | null { return null; } diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index efe01e9dd..2d50adb3f 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -12,8 +12,8 @@ import {Icon} from '../../icons/icon.js'; import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; import {RenderedConnection} from '../../rendered_connection.js'; -import {BlockCommentNavigationPolicy} from '../navigation_policies/block_comment_navigation_policy.js'; import {BlockNavigationPolicy} from '../navigation_policies/block_navigation_policy.js'; +import {BubbleNavigationPolicy} from '../navigation_policies/bubble_navigation_policy.js'; import {CommentBarButtonNavigationPolicy} from '../navigation_policies/comment_bar_button_navigation_policy.js'; import {CommentEditorNavigationPolicy} from '../navigation_policies/comment_editor_navigation_policy.js'; import {ConnectionNavigationPolicy} from '../navigation_policies/connection_navigation_policy.js'; @@ -51,7 +51,7 @@ export class Navigator { new IconNavigationPolicy(), new WorkspaceCommentNavigationPolicy(), new CommentBarButtonNavigationPolicy(), - new BlockCommentNavigationPolicy(), + new BubbleNavigationPolicy(), new CommentEditorNavigationPolicy(), ]; diff --git a/packages/blockly/tests/mocha/keyboard_navigation_test.js b/packages/blockly/tests/mocha/keyboard_navigation_test.js index 1ff150b26..21cee0462 100644 --- a/packages/blockly/tests/mocha/keyboard_navigation_test.js +++ b/packages/blockly/tests/mocha/keyboard_navigation_test.js @@ -183,6 +183,55 @@ suite('Keyboard navigation on Blocks', function () { assert.equal(getFocusedBlockId(), 'p5_setup_1'); }); + test('Right from block selects first icon', function () { + this.workspace.getBlockById('p5_canvas_1').setCommentText('hello'); + focusBlock(this.workspace, 'p5_canvas_1'); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + this.workspace + .getBlockById('p5_canvas_1') + .getIcon(Blockly.icons.IconType.COMMENT), + ); + }); + + test('Right from icon selects next icon', function () { + const block = this.workspace.getBlockById('p5_canvas_1'); + block.setCommentText('hello'); + block.setWarningText('danger!'); + const commentIcon = block.getIcon(Blockly.icons.IconType.COMMENT); + const warningIcon = block.getIcon(Blockly.icons.IconType.WARNING); + + Blockly.getFocusManager().focusNode(warningIcon); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal(Blockly.getFocusManager().getFocusedNode(), commentIcon); + }); + + test('Right from icon selects bubble', async function () { + const block = this.workspace.getBlockById('p5_canvas_1'); + block.setCommentText('hello'); + const commentIcon = block.getIcon(Blockly.icons.IconType.COMMENT); + await commentIcon.setBubbleVisible(true); + + Blockly.getFocusManager().focusNode(commentIcon); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + commentIcon.getBubble(), + ); + }); + + test('Right from last icon selects field', function () { + this.workspace.getBlockById('p5_canvas_1').setCommentText('hello'); + const icon = this.workspace + .getBlockById('p5_canvas_1') + .getIcon(Blockly.icons.IconType.COMMENT); + Blockly.getFocusManager().focusNode(icon); + pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); + assert.include(getFocusNodeId(), 'p5_canvas_1_field_'); + assert.equal(getFocusedFieldName(), 'WIDTH'); + }); + test('Right from block selects first field', function () { focusBlock(this.workspace, 'p5_canvas_1'); pressKey(this.workspace, Blockly.utils.KeyCodes.RIGHT); @@ -223,6 +272,70 @@ suite('Keyboard navigation on Blocks', function () { assert.equal(getFocusedBlockId(), 'math_number_2'); }); + test('Left from icon selects block', function () { + const block = this.workspace.getBlockById('p5_canvas_1'); + block.setCommentText('hello'); + Blockly.getFocusManager().focusNode( + block.getIcon(Blockly.icons.IconType.COMMENT), + ); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(Blockly.getFocusManager().getFocusedNode(), block); + }); + + test('Left from icon selects previous icon', function () { + const block = this.workspace.getBlockById('p5_canvas_1'); + block.setCommentText('hello'); + block.setWarningText('danger!'); + const commentIcon = block.getIcon(Blockly.icons.IconType.COMMENT); + const warningIcon = block.getIcon(Blockly.icons.IconType.WARNING); + + Blockly.getFocusManager().focusNode(commentIcon); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(Blockly.getFocusManager().getFocusedNode(), warningIcon); + }); + + test('Left from icon selects bubble', async function () { + const block = this.workspace.getBlockById('p5_canvas_1'); + block.setCommentText('hello'); + block.setWarningText('danger!'); + const commentIcon = block.getIcon(Blockly.icons.IconType.COMMENT); + const warningIcon = block.getIcon(Blockly.icons.IconType.WARNING); + const bubbleVisible = warningIcon.setBubbleVisible(true); + this.clock.runAll(); + await bubbleVisible; + + Blockly.getFocusManager().focusNode(commentIcon); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + warningIcon.getBubble(), + ); + }); + + test('Left from field selects icon', function () { + this.workspace.getBlockById('p5_canvas_1').setCommentText('hello'); + const commentIcon = this.workspace + .getBlockById('p5_canvas_1') + .getIcon(Blockly.icons.IconType.COMMENT); + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal(Blockly.getFocusManager().getFocusedNode(), commentIcon); + }); + + test('Left from field selects bubble', async function () { + this.workspace.getBlockById('p5_canvas_1').setCommentText('hello'); + const commentIcon = this.workspace + .getBlockById('p5_canvas_1') + .getIcon(Blockly.icons.IconType.COMMENT); + await commentIcon.setBubbleVisible(true); + focusBlockField(this.workspace, 'p5_canvas_1', 'WIDTH'); + pressKey(this.workspace, Blockly.utils.KeyCodes.LEFT); + assert.equal( + Blockly.getFocusManager().getFocusedNode(), + commentIcon.getBubble(), + ); + }); + test('Right from last inline input block selects next child field', function () { focusBlock(this.workspace, 'colour_picker_1'); // Go right twice; should not wrap to next row. From 86a435506b03ba0ea526cbcb5cd8db957fa489a0 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 21 Apr 2026 10:29:37 -0700 Subject: [PATCH 056/200] fix: Fix dropdowndiv animations (#9733) --- packages/blockly/core/css.ts | 2 +- packages/blockly/core/dropdowndiv.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index 73aa8b652..5d853622e 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -123,7 +123,7 @@ const content = ` left: 0; top: 0; z-index: 1000; - display: none; + visibility: hidden; border: 1px solid; border-color: #dadce0; background-color: #fff; diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index 704a767e8..75abc7204 100644 --- a/packages/blockly/core/dropdowndiv.ts +++ b/packages/blockly/core/dropdowndiv.ts @@ -760,7 +760,7 @@ function positionInternal( div.style.top = initialY + 'px'; // Show the div. - div.style.display = 'block'; + div.style.visibility = 'visible'; div.style.opacity = '1'; // Add final translate, animated through `transition`. // Coordinates are relative to (initialX, initialY), From 6e3195e223cb927398bc4b84bcfdd4a6d011688b Mon Sep 17 00:00:00 2001 From: lizschwab Date: Tue, 21 Apr 2026 11:46:43 -0700 Subject: [PATCH 057/200] feat: Update zelos path object to allowlist attributes (#9742) --- .../core/renderers/zelos/path_object.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/blockly/core/renderers/zelos/path_object.ts b/packages/blockly/core/renderers/zelos/path_object.ts index 6484be4e0..36eed1dc9 100644 --- a/packages/blockly/core/renderers/zelos/path_object.ts +++ b/packages/blockly/core/renderers/zelos/path_object.ts @@ -7,8 +7,8 @@ // Former goog.module ID: Blockly.zelos.PathObject import type {BlockSvg} from '../../block_svg.js'; -import {FocusManager} from '../../focus_manager.js'; import type {BlockStyle} from '../../theme.js'; +import {Role} 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'; @@ -89,19 +89,18 @@ 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, + // Create a shallow copy with only the attributes we need to carry over. + 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': Role.PRESENTATION, + }, + this.svgRoot, ); - dom.removeClass( - this.svgPathSelected, - FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, - ); - this.svgPathSelected.removeAttribute('tabindex'); - this.svgPathSelected.removeAttribute('id'); this.svgRoot.appendChild(this.svgPathSelected); } } else { From 36ca80aa307873baa459844a53961a16b8bb2204 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 21 Apr 2026 15:22:25 -0400 Subject: [PATCH 058/200] feat: add display text to shortcuts (#9743) * feat: add display text to shortcuts * fix: remove duplicated message --- packages/blockly/core/shortcut_items.ts | 29 +++++++++++ packages/blockly/core/shortcut_registry.ts | 7 +++ packages/blockly/msg/json/en.json | 22 +++++++- packages/blockly/msg/json/qqq.json | 22 +++++++- packages/blockly/msg/messages.js | 60 ++++++++++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index af82248d4..2b8d00d97 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -80,6 +80,7 @@ export function registerEscape() { return true; }, keyCodes: [KeyCodes.ESC], + displayText: () => Msg['SHORTCUTS_ESCAPE'], }; ShortcutRegistry.registry.register(escapeAction); } @@ -119,6 +120,7 @@ export function registerDelete() { return true; }, keyCodes: [KeyCodes.DELETE, KeyCodes.BACKSPACE], + displayText: () => Msg['SHORTCUTS_DELETE'], }; ShortcutRegistry.registry.register(deleteShortcut); } @@ -208,6 +210,7 @@ export function registerCopy() { return !!clipboard.copy(focused, copyCoords); }, keyCodes: [ctrlC], + displayText: () => Msg['COPY_SHORTCUT'], }; ShortcutRegistry.registry.register(copyShortcut); } @@ -252,6 +255,7 @@ export function registerCut() { return !!copyData; }, keyCodes: [ctrlX], + displayText: () => Msg['CUT_SHORTCUT'], }; ShortcutRegistry.registry.register(cutShortcut); @@ -334,6 +338,7 @@ export function registerPaste() { return !!clipboard.paste(copyData, targetWorkspace, centerCoords); }, keyCodes: [ctrlV], + displayText: () => Msg['PASTE_SHORTCUT'], }; ShortcutRegistry.registry.register(pasteShortcut); @@ -364,6 +369,7 @@ export function registerUndo() { return true; }, keyCodes: [ctrlZ], + displayText: () => Msg['UNDO'], }; ShortcutRegistry.registry.register(undoShortcut); } @@ -400,6 +406,7 @@ export function registerRedo() { return true; }, keyCodes: [ctrlShiftZ, ctrlY], + displayText: () => Msg['REDO'], }; ShortcutRegistry.registry.register(redoShortcut); } @@ -445,6 +452,7 @@ export function registerMovementShortcuts() { ); }, keyCodes: [KeyCodes.M], + displayText: () => Msg['SHORTCUTS_START_MOVE'], }; const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [ startMoveShortcut, @@ -452,6 +460,7 @@ export function registerMovementShortcuts() { ...startMoveShortcut, name: names.START_MOVE_STACK, keyCodes: [shiftM], + displayText: () => Msg['SHORTCUTS_START_MOVE_STACK'], }, { name: names.FINISH_MOVE, @@ -460,6 +469,7 @@ export function registerMovementShortcuts() { KeyboardMover.mover.finishMove(e as KeyboardEvent), keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE], allowCollision: true, + displayText: () => Msg['SHORTCUTS_FINISH_MOVE'], }, { name: names.ABORT_MOVE, @@ -468,6 +478,7 @@ export function registerMovementShortcuts() { KeyboardMover.mover.abortMove(e as KeyboardEvent), keyCodes: [KeyCodes.ESC], allowCollision: true, + displayText: () => Msg['SHORTCUTS_ABORT_MOVE'], }, { name: names.MOVE_LEFT, @@ -483,6 +494,7 @@ export function registerMovementShortcuts() { ]), ], allowCollision: true, + displayText: () => Msg['SHORTCUTS_MOVE_LEFT'], }, { name: names.MOVE_RIGHT, @@ -498,6 +510,7 @@ export function registerMovementShortcuts() { ]), ], allowCollision: true, + displayText: () => Msg['SHORTCUTS_MOVE_RIGHT'], }, { name: names.MOVE_UP, @@ -513,6 +526,7 @@ export function registerMovementShortcuts() { ]), ], allowCollision: true, + displayText: () => Msg['SHORTCUTS_MOVE_UP'], }, { name: names.MOVE_DOWN, @@ -528,6 +542,7 @@ export function registerMovementShortcuts() { ]), ], allowCollision: true, + displayText: () => Msg['SHORTCUTS_MOVE_DOWN'], }, ]; @@ -562,6 +577,7 @@ export function registerShowContextMenu() { return false; }, keyCodes: [ctrlEnter], + displayText: () => Msg['SHORTCUTS_SHOW_CONTEXT_MENU'], }; ShortcutRegistry.registry.register(contextMenuShortcut); } @@ -592,6 +608,7 @@ export function registerArrowNavigation() { }, keyCodes: [KeyCodes.RIGHT], allowCollision: true, + displayText: () => Msg['SHORTCUTS_NAVIGATE_RIGHT'], }, /** Go to the next location to the left. */ @@ -613,6 +630,7 @@ export function registerArrowNavigation() { }, keyCodes: [KeyCodes.LEFT], allowCollision: true, + displayText: () => Msg['SHORTCUTS_NAVIGATE_LEFT'], }, /** Go down to the next location. */ @@ -635,6 +653,7 @@ export function registerArrowNavigation() { }, keyCodes: [KeyCodes.DOWN], allowCollision: true, + displayText: () => Msg['SHORTCUTS_NAVIGATE_DOWN'], }, /** Go up to the previous location. */ up: { @@ -656,6 +675,7 @@ export function registerArrowNavigation() { }, keyCodes: [KeyCodes.UP], allowCollision: true, + displayText: () => Msg['SHORTCUTS_NAVIGATE_UP'], }, }; @@ -687,6 +707,7 @@ export function registerFocusWorkspace() { return true; }, keyCodes: [KeyCodes.W], + displayText: () => Msg['SHORTCUTS_FOCUS_WORKSPACE'], }; ShortcutRegistry.registry.register(focusWorkspaceShortcut); } @@ -714,6 +735,7 @@ export function registerFocusToolbox() { } }, keyCodes: [KeyCodes.T], + displayText: () => Msg['SHORTCUTS_FOCUS_TOOLBOX'], }; ShortcutRegistry.registry.register(focusToolboxShortcut); } @@ -763,6 +785,7 @@ export function registerWorkspaceOverview() { return true; }, keyCodes: [KeyCodes.I], + displayText: () => Msg['SHORTCUTS_INFORMATION'], }; ShortcutRegistry.registry.register(shortcut); } @@ -790,6 +813,7 @@ export function registerDisconnectBlock() { return true; }, keyCodes: [KeyCodes.X, shiftX], + displayText: () => Msg['SHORTCUTS_DISCONNECT'], }; ShortcutRegistry.registry.register(disconnectShortcut); } @@ -838,6 +862,7 @@ export function registerStackNavigation() { return true; }, keyCodes: [KeyCodes.N], + displayText: () => Msg['SHORTCUTS_NEXT_STACK'], }; const previousStackShortcut: KeyboardShortcut = { @@ -863,6 +888,7 @@ export function registerStackNavigation() { return true; }, keyCodes: [KeyCodes.B], + displayText: () => Msg['SHORTCUTS_PREVIOUS_STACK'], }; ShortcutRegistry.registry.register(nextStackShortcut); @@ -891,6 +917,7 @@ export function registerPerformAction() { }, keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE], allowCollision: true, + displayText: () => Msg['SHORTCUTS_PERFORM_ACTION'], }; ShortcutRegistry.registry.register(performActionShortcut); } @@ -919,6 +946,7 @@ export function registerDuplicate() { }, keyCodes: [KeyCodes.D], allowCollision: true, + displayText: () => Msg['SHORTCUTS_DUPLICATE'], }; ShortcutRegistry.registry.register(duplicateShortcut); } @@ -938,6 +966,7 @@ export function registerCleanup() { }, keyCodes: [KeyCodes.C], allowCollision: true, + displayText: () => Msg['SHORTCUTS_CLEANUP'], }; ShortcutRegistry.registry.register(cleanupShortcut); } diff --git a/packages/blockly/core/shortcut_registry.ts b/packages/blockly/core/shortcut_registry.ts index f40149db8..c5d820ff7 100644 --- a/packages/blockly/core/shortcut_registry.ts +++ b/packages/blockly/core/shortcut_registry.ts @@ -434,6 +434,13 @@ export namespace ShortcutRegistry { * name. */ allowCollision?: boolean; + + /** + * Display text for the shortcut. This is not used in core but may + * be used by plugins or applications to provide a user-facing + * label for the shortcut. + */ + displayText?: string | (() => string); } /** Supported modifiers. */ diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index a1c94aae0..0812fa3d4 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-20 12:26:14.946401", + "lastupdated": "2026-04-21 12:36:42.927221", "locale": "en", "messagedocumentation" : "qqq" }, @@ -417,6 +417,26 @@ "SHORTCUTS_GENERAL": "General", "SHORTCUTS_EDITING": "Editing", "SHORTCUTS_CODE_NAVIGATION": "Code navigation", + "SHORTCUTS_ESCAPE": "Exit", + "SHORTCUTS_DELETE": "Delete", + "SHORTCUTS_START_MOVE": "Start move", + "SHORTCUTS_START_MOVE_STACK": "Start move stack", + "SHORTCUTS_MOVE_LEFT": "Move left", + "SHORTCUTS_MOVE_RIGHT": "Move right", + "SHORTCUTS_MOVE_UP": "Move up", + "SHORTCUTS_MOVE_DOWN": "Move down", + "SHORTCUTS_FINISH_MOVE": "Finish move", + "SHORTCUTS_ABORT_MOVE": "Abort move", + "SHORTCUTS_SHOW_CONTEXT_MENU": "Show menu", + "SHORTCUTS_FOCUS_WORKSPACE": "Focus workspace", + "SHORTCUTS_FOCUS_TOOLBOX": "Focus toolbox", + "SHORTCUTS_INFORMATION": "Announce information", + "SHORTCUTS_DISCONNECT": "Disconnect block", + "SHORTCUTS_NEXT_STACK": "Next stack", + "SHORTCUTS_PREVIOUS_STACK": "Previous stack", + "SHORTCUTS_PERFORM_ACTION": "Edit or confirm", + "SHORTCUTS_DUPLICATE": "Duplicate", + "SHORTCUTS_CLEANUP": "Clean up workspace", "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Hold %1 and use arrow keys to move freely, then %2 to accept the position", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position", "KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.", diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 0fa1a2cd6..5a1472db2 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -1,5 +1,5 @@ { - "@metadata": { + "@metadata": { "authors": [ "Ajeje Brazorf", "Amire80", @@ -424,6 +424,26 @@ "SHORTCUTS_GENERAL": "shortcut list section header - Label for general purpose keyboard shortcuts.", "SHORTCUTS_EDITING": "shortcut list section header - Label for keyboard shortcuts related to editing a workspace.", "SHORTCUTS_CODE_NAVIGATION": "shortcut list section header - Label for keyboard shortcuts related to moving around the workspace.", + "SHORTCUTS_ESCAPE": "shortcut display text for the escape shortcut, which is used in various contexts to exit or cancel an action.", + "SHORTCUTS_DELETE": "shortcut display text for the delete shortcut, which is used in various contexts to delete items.", + "SHORTCUTS_START_MOVE": "shortcut display text for the start move shortcut, which enters the keyboard navigation 'move mode'.", + "SHORTCUTS_START_MOVE_STACK": "shortcut display text for the start move stack shortcut, which enters the keyboard navigation 'move mode' for a stack of blocks.", + "SHORTCUTS_MOVE_LEFT": "shortcut display text for the move left shortcut, which moves a block left during keyboard navigation.", + "SHORTCUTS_MOVE_RIGHT": "shortcut display text for the move right shortcut, which moves a block right during keyboard navigation.", + "SHORTCUTS_MOVE_UP": "shortcut display text for the move up shortcut, which moves a block up during keyboard navigation.", + "SHORTCUTS_MOVE_DOWN": "shortcut display text for the move down shortcut, which moves a block down during keyboard navigation.", + "SHORTCUTS_FINISH_MOVE": "shortcut display text for the finish move shortcut, which exits the keyboard navigation 'move mode'.", + "SHORTCUTS_ABORT_MOVE": "shortcut display text for the abort move shortcut, which cancels the keyboard navigation 'move mode'.", + "SHORTCUTS_SHOW_CONTEXT_MENU": "shortcut display text for the shortcut that opens the context menu.", + "SHORTCUTS_FOCUS_WORKSPACE": "shortcut display text for the focus workspace shortcut, which moves focus to the workspace.", + "SHORTCUTS_FOCUS_TOOLBOX": "shortcut display text for the focus toolbox shortcut, which moves focus to the toolbox or flyout.", + "SHORTCUTS_INFORMATION": "shortcut display text for the information shortcut, which announces information about a focused element.", + "SHORTCUTS_DISCONNECT": "shortcut display text for the disconnect shortcut, which disconnects a block from its neighbor.", + "SHORTCUTS_NEXT_STACK": "shortcut display text for the next stack shortcut, which navigates to the next block stack.", + "SHORTCUTS_PREVIOUS_STACK": "shortcut display text for the previous stack shortcut, which navigates to the previous block stack.", + "SHORTCUTS_PERFORM_ACTION": "shortcut display text for the perform action shortcut, which triggers an action on the focused element.", + "SHORTCUTS_DUPLICATE": "shortcut display text for the duplicate shortcut, which duplicates the focused block or comment.", + "SHORTCUTS_CLEANUP": "shortcut display text for the cleanup shortcut, which organizes blocks on the workspace.", "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks to arbitrary locations with the keyboard.", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.", "KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 9d6162eea..8c8271033 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1684,6 +1684,66 @@ Blockly.Msg.SHORTCUTS_EDITING = 'Editing' /// moving around the workspace. Blockly.Msg.SHORTCUTS_CODE_NAVIGATION = 'Code navigation'; /** @type {string} */ +/// shortcut display text for the escape shortcut, which is used in various contexts to exit or cancel an action. +Blockly.Msg.SHORTCUTS_ESCAPE = 'Exit'; +/** @type {string} */ +/// shortcut display text for the delete shortcut, which is used in various contexts to delete items. +Blockly.Msg.SHORTCUTS_DELETE = 'Delete'; +/** @type {string} */ +/// shortcut display text for the start move shortcut, which enters the keyboard navigation "move mode". +Blockly.Msg.SHORTCUTS_START_MOVE = 'Start move'; +/** @type {string} */ +/// shortcut display text for the start move stack shortcut, which enters the keyboard navigation "move mode" for a stack of blocks. +Blockly.Msg.SHORTCUTS_START_MOVE_STACK = 'Start move stack'; +/** @type {string} */ +/// shortcut display text for the move left shortcut, which moves a block left during keyboard navigation. +Blockly.Msg.SHORTCUTS_MOVE_LEFT = 'Move left'; +/** @type {string} */ +/// shortcut display text for the move right shortcut, which moves a block right during keyboard navigation. +Blockly.Msg.SHORTCUTS_MOVE_RIGHT = 'Move right'; +/** @type {string} */ +/// shortcut display text for the move up shortcut, which moves a block up during keyboard navigation. +Blockly.Msg.SHORTCUTS_MOVE_UP = 'Move up'; +/** @type {string} */ +/// shortcut display text for the move down shortcut, which moves a block down during keyboard navigation. +Blockly.Msg.SHORTCUTS_MOVE_DOWN = 'Move down'; +/** @type {string} */ +/// shortcut display text for the finish move shortcut, which exits the keyboard navigation "move mode". +Blockly.Msg.SHORTCUTS_FINISH_MOVE = 'Finish move'; +/** @type {string} */ +/// shortcut display text for the abort move shortcut, which cancels the keyboard navigation "move mode". +Blockly.Msg.SHORTCUTS_ABORT_MOVE = 'Abort move'; +/** @type {string} */ +/// shortcut display text for the shortcut that opens the context menu. +Blockly.Msg.SHORTCUTS_SHOW_CONTEXT_MENU = 'Show menu'; +/** @type {string} */ +/// shortcut display text for the focus workspace shortcut, which moves focus to the workspace. +Blockly.Msg.SHORTCUTS_FOCUS_WORKSPACE = 'Focus workspace'; +/** @type {string} */ +/// shortcut display text for the focus toolbox shortcut, which moves focus to the toolbox or flyout. +Blockly.Msg.SHORTCUTS_FOCUS_TOOLBOX = 'Focus toolbox'; +/** @type {string} */ +/// shortcut display text for the information shortcut, which announces information about a focused element. +Blockly.Msg.SHORTCUTS_INFORMATION = 'Announce information'; +/** @type {string} */ +/// shortcut display text for the disconnect shortcut, which disconnects a block from its neighbor. +Blockly.Msg.SHORTCUTS_DISCONNECT = 'Disconnect block'; +/** @type {string} */ +/// shortcut display text for the next stack shortcut, which navigates to the next block stack. +Blockly.Msg.SHORTCUTS_NEXT_STACK = 'Next stack'; +/** @type {string} */ +/// shortcut display text for the previous stack shortcut, which navigates to the previous block stack. +Blockly.Msg.SHORTCUTS_PREVIOUS_STACK = 'Previous stack'; +/** @type {string} */ +/// shortcut display text for the perform action shortcut, which triggers an action on the focused element. +Blockly.Msg.SHORTCUTS_PERFORM_ACTION = 'Edit or confirm'; +/** @type {string} */ +/// shortcut display text for the duplicate shortcut, which duplicates the focused block or comment. +Blockly.Msg.SHORTCUTS_DUPLICATE = 'Duplicate'; +/** @type {string} */ +/// shortcut display text for the cleanup shortcut, which organizes blocks on the workspace. +Blockly.Msg.SHORTCUTS_CLEANUP = 'Clean up workspace'; +/** @type {string} */ /// Message shown to inform users how to move blocks to arbitrary locations /// with the keyboard. Blockly.Msg.KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT = 'Hold %1 and use arrow keys to move freely, then %2 to accept the position'; From 9c0846bfab8c98b70883bc3e261d6ba9f1e57410 Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:23:18 -0400 Subject: [PATCH 059/200] feat: `FieldInput` ARIA (#9744) * feat: `FieldInput' ARIA * chore: update tsdocs * chore: lint fix * fix: use aria util for setting role * fix: use single empty field message --- packages/blockly/core/block_aria_composer.ts | 23 +++++--- packages/blockly/core/field_input.ts | 54 +++++++++++++++++++ packages/blockly/core/inputs/input.ts | 9 ++-- packages/blockly/msg/json/en.json | 6 ++- packages/blockly/msg/json/qqq.json | 6 ++- packages/blockly/msg/messages.js | 10 +++- .../blockly/tests/mocha/field_number_test.js | 46 ++++++++++++++++ .../tests/mocha/field_textinput_test.js | 47 ++++++++++++++++ 8 files changed, 186 insertions(+), 15 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 925512307..256a68534 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -60,7 +60,7 @@ export function computeAriaLabel( return [ verbosity >= Verbosity.STANDARD && getBeginStackLabel(block), getParentInputLabel(block), - ...getInputLabels(block), + ...getInputLabels(block, verbosity), verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), verbosity >= Verbosity.STANDARD && getDisabledLabel(block), verbosity >= Verbosity.STANDARD && getCollapsedLabel(block), @@ -111,15 +111,17 @@ export function configureAriaRole(block: BlockSvg) { export function computeFieldRowLabel( input: Input, lookback: boolean, + verbosity = Verbosity.STANDARD, ): string[] { + const includeTypeInfo = verbosity >= Verbosity.STANDARD; const fieldRowLabel = input.fieldRow .filter((field) => field.isVisible()) - .map((field) => field.computeAriaLabel(true)); + .map((field) => field.computeAriaLabel(includeTypeInfo)); if (!fieldRowLabel.length && lookback) { const inputs = input.getSourceBlock().inputList; const index = inputs.indexOf(input); if (index > 0) { - return computeFieldRowLabel(inputs[index - 1], lookback); + return computeFieldRowLabel(inputs[index - 1], lookback, verbosity); } } return fieldRowLabel; @@ -186,10 +188,13 @@ function getBeginStackLabel(block: BlockSvg) { * @param block The block to retrieve a list of field/input labels for. * @returns A list of field/input labels for the given block. */ -export function getInputLabels(block: BlockSvg): string[] { +export function getInputLabels( + block: BlockSvg, + verbosity = Verbosity.STANDARD, +): string[] { return block.inputList .filter((input) => input.isVisible()) - .map((input) => input.getLabel()); + .map((input) => input.getLabel(verbosity)); } /** @@ -208,7 +213,11 @@ export function getInputLabels(block: BlockSvg): string[] { * @param input The input that defines the end of the subset. * @returns A list of field/input labels for the given block. */ -export function getInputLabelsSubset(block: BlockSvg, input: Input): string[] { +export function getInputLabelsSubset( + block: BlockSvg, + input: Input, + verbosity = Verbosity.STANDARD, +): string[] { const inputIndex = block.inputList.indexOf(input); if (inputIndex === -1) { throw new Error( @@ -226,7 +235,7 @@ export function getInputLabelsSubset(block: BlockSvg, input: Input): string[] { .filter((input) => input.isVisible()) .map( (input) => - input.getLabel() || + input.getLabel(verbosity) || Msg['INPUT_LABEL_INDEX'].replace( '%1', (input.getIndex() + 1).toString(), diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index ac3717b59..5f024bdab 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -175,6 +175,7 @@ export abstract class FieldInput extends Field< if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyInputField'); } + this.recomputeAriaContext(); } override isFullBlockField(): boolean { @@ -224,6 +225,7 @@ export abstract class FieldInput extends Field< ); } } + this.recomputeAriaContext(); } /** @@ -238,6 +240,7 @@ export abstract class FieldInput extends Field< this.isDirty_ = true; this.isTextValid_ = true; this.value_ = newValue; + this.recomputeAriaContext(); } /** @@ -807,6 +810,57 @@ export abstract class FieldInput extends Field< protected getValueFromEditorText_(text: string): AnyDuringMigration { return text; } + + /** + * Gets an ARIA-friendly label representation of this field's type. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's type. + * + * @returns An ARIA representation of the field's type or a default if it is + * unspecified. + */ + override getAriaTypeName(): string | null { + return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_INPUT']; + } + + /** + * Gets an ARIA-friendly label representation of this field's value. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's value. + * + * @returns An ARIA representation of the field's text. + */ + override getAriaValue(): string | null { + return this.getText() || Msg['FIELD_LABEL_EMPTY']; + } + + /** + * Recomputes the ARIA role and label for this field. + */ + private recomputeAriaContext(): void { + const focusableElement = this.getClickTarget_(); + if (!focusableElement) return; + + if (this.getSourceBlock()?.isInFlyout) { + aria.setState(focusableElement, aria.State.HIDDEN, true); + return; + } + + aria.setState(focusableElement, aria.State.HIDDEN, false); + // The button role is intended to indicate to users that the field has an + // editing mode that can be activated. + aria.setRole(focusableElement, aria.Role.BUTTON); + + let label = this.computeAriaLabel(false); + + if (this.isCurrentlyEditable?.()) { + label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label); + } + + aria.setState(focusableElement, aria.State.LABEL, label); + } } /** diff --git a/packages/blockly/core/inputs/input.ts b/packages/blockly/core/inputs/input.ts index 8eb27387c..86171316e 100644 --- a/packages/blockly/core/inputs/input.ts +++ b/packages/blockly/core/inputs/input.ts @@ -22,6 +22,7 @@ import {ConnectionType} from '../connection_type.js'; import type {Field} from '../field.js'; import * as fieldRegistry from '../field_registry.js'; import {RenderedConnection} from '../rendered_connection.js'; +import {Verbosity} from '../utils/aria.js'; import {Align} from './align.js'; import {inputTypes} from './input_types.js'; @@ -356,15 +357,17 @@ export class Input { * * @internal */ - getLabel(): string { + getLabel(verbosity = Verbosity.STANDARD): string { if (!this.isVisible()) return ''; - const labels = computeFieldRowLabel(this, false); + const labels = computeFieldRowLabel(this, false, verbosity); if (this.connection?.type === ConnectionType.INPUT_VALUE) { const childBlock = this.connection.targetBlock(); if (childBlock && !childBlock.isInsertionMarker()) { - labels.push(getInputLabels(childBlock as BlockSvg).join(' ')); + labels.push( + getInputLabels(childBlock as BlockSvg, verbosity).join(' '), + ); } } return labels.join(' '); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 0812fa3d4..2f5709dae 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-21 12:36:42.927221", + "lastupdated": "2026-04-21 16:21:15.987859", "locale": "en", "messagedocumentation" : "qqq" }, @@ -468,5 +468,7 @@ "ANNOUNCE_MOVE_AROUND": "moving %1 %2 around %3", "ANNOUNCE_MOVE_TO": "moving %1 %2 to %3 %4", "ANNOUNCE_MOVE_CANCELED": "Canceled movement", - "FIELD_LABEL_EMPTY": "empty" + "FIELD_LABEL_EMPTY": "empty", + "ARIA_TYPE_FIELD_INPUT": "input field", + "FIELD_LABEL_EDIT_PREFIX": "Edit %1" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 5a1472db2..bc96e9f73 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -1,5 +1,5 @@ { - "@metadata": { + "@metadata": { "authors": [ "Ajeje Brazorf", "Amire80", @@ -475,5 +475,7 @@ "ANNOUNCE_MOVE_AROUND": "ARIA live region message announcing a block is being moved around another block, optionally including connection-specific label for disambiguation. \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks \n* %2 - optional phrase describing the local connection label \n* %3 - the label of the target (neighbour) block \n\nExamples:\n* 'moving around print abc'\n* 'moving if, do else statement around print abc'", "ANNOUNCE_MOVE_TO": "ARIA live region message announcing a block is being moved to a workspace location where the relationship is not specifically known. \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks \n* %2 - optional phrase describing the local connection label \n* %3 - the label of the target (neighbour) block or location \n* %4 - optional phrase describing the target connection label \n\nExamples:\n* 'moving to repeat 10, times, do'\n* 'moving 2 stack blocks else statement to repeat 10, times, do previous connection'", "ANNOUNCE_MOVE_CANCELED": "ARIA live region message announcing a block movement has been canceled.", - "FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content." + "FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content.", + "ARIA_TYPE_FIELD_INPUT": "ARIA type name for an input field, used by screen readers to identify the type of field.", + "FIELD_LABEL_EDIT_PREFIX": "Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. \n\nParameters:\n* %1 - the label of the field's value \n\nExamples:\n* 'Edit 5'\n* 'Edit item'" } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 8c8271033..f1705750d 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1881,4 +1881,12 @@ Blockly.Msg.ANNOUNCE_MOVE_TO = 'moving %1 %2 to %3 %4'; Blockly.Msg.ANNOUNCE_MOVE_CANCELED = 'Canceled movement'; /** @type {string} */ /// Label for an empty field, used by screen readers to identify fields that have no content. -Blockly.Msg.FIELD_LABEL_EMPTY = 'empty'; \ No newline at end of file +Blockly.Msg.FIELD_LABEL_EMPTY = 'empty'; +/** @type {string} */ +/// ARIA type name for an input field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_INPUT = 'input field'; +/** @type {string} */ +/// Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. +/// \n\nParameters:\n* %1 - the label of the field's value +/// \n\nExamples:\n* "Edit 5"\n* "Edit item" +Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1'; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index 3c12fed82..692633bfa 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -502,4 +502,50 @@ suite('Number Fields', function () { this.assertValue(1.7976931348623157e308); }); }); + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + const block = this.workspace.newBlock('math_number'); + this.field = block.getField('NUM'); + block.initSvg(); + block.render(); + + this.focusableElement = this.field.getClickTarget_(); + }); + test('Focusable element has role of button', function () { + const role = this.focusableElement.getAttribute('role'); + assert.equal(role, 'button'); + }); + test('Hidden when in a flyout', function () { + this.field.getSourceBlock().isInFlyout = true; + // Force recompute of ARIA label. + this.field.setValue(this.field.getValue()); + const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); + assert.equal(ariaHidden, 'true'); + }); + test('Has an ARIA label by default', function () { + const label = this.focusableElement.getAttribute('aria-label'); + assert.isTrue(label.includes('0')); + }); + test('Has Edit prefix if editable', function () { + const label = this.focusableElement.getAttribute('aria-label'); + assert.isTrue(label.includes('Edit')); + }); + test('Does not have Edit prefix if not editable', function () { + this.field.EDITABLE = false; + // Force recompute of ARIA label. + this.field.setValue(this.field.getValue()); + const label = this.focusableElement.getAttribute('aria-label'); + assert.isFalse(label.includes('Edit')); + }); + test('setValue updates ARIA label', function () { + const initialLabel = this.focusableElement.getAttribute('aria-label'); + assert.isTrue(initialLabel.includes('0')); + this.field.setValue(1); + const updatedLabel = this.focusableElement.getAttribute('aria-label'); + assert.isTrue(updatedLabel.includes('1')); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index 0af0efbab..0ab0c7452 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -592,4 +592,51 @@ suite('Text Input Fields', function () { }); }); }); + + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + const block = this.workspace.newBlock('text'); + this.field = block.getField('TEXT'); + block.initSvg(); + block.render(); + + this.focusableElement = this.field.getClickTarget_(); + }); + test('Focusable element has role of button', function () { + const role = this.focusableElement.getAttribute('role'); + assert.equal(role, 'button'); + }); + test('Hidden when in a flyout', function () { + this.field.getSourceBlock().isInFlyout = true; + // Force recompute of ARIA label. + this.field.setValue(this.field.getValue()); + const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); + assert.equal(ariaHidden, 'true'); + }); + test('Has placeholder ARIA label by default', function () { + const label = this.focusableElement.getAttribute('aria-label'); + assert.isTrue(label.includes('empty')); + }); + test('Has Edit prefix if editable', function () { + const label = this.focusableElement.getAttribute('aria-label'); + assert.isTrue(label.includes('Edit')); + }); + test('Does not have Edit prefix if not editable', function () { + this.field.EDITABLE = false; + // Force recompute of ARIA label. + this.field.setValue(this.field.getValue()); + const label = this.focusableElement.getAttribute('aria-label'); + assert.isFalse(label.includes('Edit')); + }); + test('setValue updates ARIA label', function () { + const initialLabel = this.focusableElement.getAttribute('aria-label'); + assert.isTrue(initialLabel.includes('empty')); + this.field.setValue('new value'); + const updatedLabel = this.focusableElement.getAttribute('aria-label'); + assert.isTrue(updatedLabel.includes('new value')); + }); + }); }); From 59b05f4aa93c11fcf2a18036fe9c0184e61fa379 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 22 Apr 2026 10:56:39 -0400 Subject: [PATCH 060/200] fix: use correct key names for key codes (#9746) --- packages/blockly/core/hints.ts | 4 +- .../blockly/core/utils/shortcut_formatting.ts | 137 ++++++++++++++++-- packages/blockly/msg/json/en.json | 16 +- packages/blockly/msg/json/qqq.json | 14 ++ packages/blockly/msg/messages.js | 42 ++++++ 5 files changed, 195 insertions(+), 18 deletions(-) diff --git a/packages/blockly/core/hints.ts b/packages/blockly/core/hints.ts index 25b966183..6c88f1ac5 100644 --- a/packages/blockly/core/hints.ts +++ b/packages/blockly/core/hints.ts @@ -6,7 +6,7 @@ import {Msg} from './msg.js'; import {Toast} from './toast.js'; -import {getShortActionShortcut} from './utils/shortcut_formatting.js'; +import {getShortcutKeysShort} from './utils/shortcut_formatting.js'; import * as userAgent from './utils/useragent.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -73,7 +73,7 @@ export function clearMoveHints(workspace: WorkspaceSvg) { * @param workspace The workspace. */ export function showHelpHint(workspace: WorkspaceSvg) { - const shortcut = getShortActionShortcut('list_shortcuts'); + const shortcut = getShortcutKeysShort('list_shortcuts'); if (!shortcut) return; const message = Msg['HELP_PROMPT'].replace('%1', shortcut); diff --git a/packages/blockly/core/utils/shortcut_formatting.ts b/packages/blockly/core/utils/shortcut_formatting.ts index 1009777c0..5130d72bf 100644 --- a/packages/blockly/core/utils/shortcut_formatting.ts +++ b/packages/blockly/core/utils/shortcut_formatting.ts @@ -12,12 +12,11 @@ import * as userAgent from './useragent.js'; * Find the primary shortcut for this platform and return it as single string * in a short user facing format. * - * @internal - * @param action The action name, e.g. "cut". + * @param shortcutName The keyboard shortcut name, e.g. "cut". * @returns The formatted shortcut. */ -export function getShortActionShortcut(action: string): string { - const shortcuts = getActionShortcutsAsKeys(action, shortModifierNames); +export function getShortcutKeysShort(shortcutName: string): string { + const shortcuts = getShortcutKeys(shortcutName, shortModifierNames); if (shortcuts.length) { const parts = shortcuts[0]; return parts.join(userAgent.APPLE ? ' ' : ' + '); @@ -27,15 +26,14 @@ export function getShortActionShortcut(action: string): string { } /** - * Find the relevant shortcuts for the given action for the current platform. + * Find the relevant shortcuts for the given shortcut for the current platform. * Keys are returned in a long user facing format, e.g. "Command ⌘ Option ⌥ C" * - * @internal - * @param action The action name, e.g. "cut". + * @param shortcutName The keyboard shortcut name, e.g. "cut". * @returns The formatted shortcuts as individual keys. */ -export function getLongActionShortcutsAsKeys(action: string): string[][] { - return getActionShortcutsAsKeys(action, longModifierNames); +export function getShortcutKeysLong(shortcutName: string): string[][] { + return getShortcutKeys(shortcutName, longModifierNames); } const longModifierNames: Record = { @@ -51,21 +49,130 @@ const shortModifierNames: Record = { }; /** - * Find the relevant shortcuts for the given action for the current platform. + * Key names for common characters. These should be used with keyup/keydown + * events, since the .keyCode property on those is meant to indicate the + * _physical key_ the user held down on the keyboard. Hence the mapping uses + * only the unshifted version of each key (e.g. no '#', since that's shift+3). + * Keypress events on the other hand generate (mostly) ASCII codes since they + * correspond to *characters* the user typed. + * + * For further reference: http://unixpapa.com/js/key.html + * + * This list is not localized and therefore some of the key codes are not + * correct for non-US keyboard layouts. + * + * Partially copied from goog.events.keynames and modified to use translatable + * strings or symbols for keys. + */ +const keyNames: Record = { + 8: Msg['BACKSPACE_KEY'], + 9: Msg['TAB_KEY'], + 13: Msg['ENTER_KEY'], + 16: Msg['SHIFT_KEY'], + 17: Msg['CTRL_KEY'], + 18: Msg['ALT_KEY'], + 19: Msg['PAUSE_KEY'], + 20: Msg['CAPS_LOCK_KEY'], + 27: Msg['ESCAPE_KEY'], + 32: Msg['SPACE_KEY'], + 33: Msg['PAGE_UP_KEY'], + 34: Msg['PAGE_DOWN_KEY'], + 35: Msg['END_KEY'], + 36: Msg['HOME_KEY'], + 37: '←', + 38: '↑', + 39: '→', + 40: '↓', + 45: Msg['INSERT_KEY'], + 46: Msg['DELETE_KEY'], + 48: '0', + 49: '1', + 50: '2', + 51: '3', + 52: '4', + 53: '5', + 54: '6', + 55: '7', + 56: '8', + 57: '9', + 59: ';', + 61: '=', + 93: Msg['CONTEXT_MENU_KEY'], + 96: '0', + 97: '1', + 98: '2', + 99: '3', + 100: '4', + 101: '5', + 102: '6', + 103: '7', + 104: '8', + 105: '9', + 106: '×', + 107: '+', + 109: '−', + 110: '.', + 111: '÷', + 112: 'F1', + 113: 'F2', + 114: 'F3', + 115: 'F4', + 116: 'F5', + 117: 'F6', + 118: 'F7', + 119: 'F8', + 120: 'F9', + 121: 'F10', + 122: 'F11', + 123: 'F12', + 186: ';', + 187: '=', + 189: '-', + 188: ',', + 190: '.', + 191: '/', + 192: '`', + 219: '[', + 220: '\\', + 221: ']', + 222: "'", + 224: '⌘', +}; + +/** + * Gets a user-facing name for a keycode. + * + * @param keyCode + * @returns key name, or the character for the keycode if it's not in the list of known keys. + */ +function getKeyName(keyCode: number): string { + if (keyCode >= 65 && keyCode <= 90) { + // letters a-z + return String.fromCharCode(keyCode); + } + const keyName = keyNames[keyCode]; + if (keyName) return keyName; + console.warn('Unknown key code: ' + keyCode); + return String.fromCharCode(keyCode); +} + +/** + * Find the relevant shortcuts for the given shortcut for the current platform. * Keys are returned in a short user facing format, e.g. "⌘ ⌥ C" * * This could be considerably simpler if we only bound shortcuts relevant to the * current platform or tagged them with a platform. * - * @param action The action name, e.g. "cut". + * @param shortcutName The keyboard shortcut name, e.g. "cut". * @param modifierNames The names to use for the Meta/Control/Alt modifiers. * @returns The formatted shortcuts. */ -function getActionShortcutsAsKeys( - action: string, +function getShortcutKeys( + shortcutName: string, modifierNames: Record, ): string[][] { - const shortcuts = ShortcutRegistry.registry.getKeyCodesByShortcutName(action); + const shortcuts = + ShortcutRegistry.registry.getKeyCodesByShortcutName(shortcutName); if (shortcuts.length === 0) { return []; } @@ -109,7 +216,7 @@ function getActionShortcutsAsKeys( return shortcut .map((maybeNumeric) => Number.isFinite(+maybeNumeric) - ? String.fromCharCode(+maybeNumeric) + ? getKeyName(+maybeNumeric) : maybeNumeric, ) .map((k) => upperCaseFirst(modifierNames[k] ?? k)); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 2f5709dae..12fbfe682 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-21 16:21:15.987859", + "lastupdated": "2026-04-21 17:30:08.288719", "locale": "en", "messagedocumentation" : "qqq" }, @@ -410,6 +410,20 @@ "OPTION_KEY": "⌥ Option", "ALT_KEY": "Alt", "ENTER_KEY": "Enter", + "BACKSPACE_KEY": "Backspace", + "DELETE_KEY": "Delete", + "ESCAPE": "Esc", + "TAB_KEY": "Tab", + "SHIFT_KEY": "Shift", + "CAPS_LOCK_KEY": "Caps Lock", + "SPACE_KEY": "Space", + "PAGE_UP_KEY": "Page Up", + "PAGE_DOWN_KEY": "Page Down", + "END_KEY": "End", + "HOME_KEY": "Home", + "INSERT_KEY": "Insert", + "PAUSE_KEY": "Pause", + "CONTEXT_MENU_KEY": "≣ Menu", "CUT_SHORTCUT": "Cut", "COPY_SHORTCUT": "Copy", "PASTE_SHORTCUT": "Paste", diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index bc96e9f73..d2fcf86cb 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -417,6 +417,20 @@ "OPTION_KEY": "Representation of the Mac Option key used in keyboard shortcuts.", "ALT_KEY": "Representation of the Alt key used in keyboard shortcuts.", "ENTER_KEY": "Representation of the Enter key used in keyboard shortcuts.", + "BACKSPACE_KEY": "Representation of the Backspace key used in keyboard shortcuts.", + "DELETE_KEY": "Representation of the Delete key used in keyboard shortcuts.", + "ESCAPE": "Representation of the Escape key used in keyboard shortcuts.", + "TAB_KEY": "Representation of the Tab key used in keyboard shortcuts.", + "SHIFT_KEY": "Representation of the Shift key used in keyboard shortcuts.", + "CAPS_LOCK_KEY": "Representation of the Caps Lock key used in keyboard shortcuts.", + "SPACE_KEY": "Representation of the Space key used in keyboard shortcuts.", + "PAGE_UP_KEY": "Representation of the Page Up key used in keyboard shortcuts.", + "PAGE_DOWN_KEY": "Representation of the Page Down key used in keyboard shortcuts.", + "END_KEY": "Representation of the End key used in keyboard shortcuts.", + "HOME_KEY": "Representation of the Home key used in keyboard shortcuts.", + "INSERT_KEY": "Representation of the Insert key used in keyboard shortcuts.", + "PAUSE_KEY": "Representation of the Pause key used in keyboard shortcuts.", + "CONTEXT_MENU_KEY": "Representation of the Context Menu key used in keyboard shortcuts.", "CUT_SHORTCUT": "menu label - Contextual menu item that cuts the focused item.", "COPY_SHORTCUT": "menu label - Contextual menu item that copies the focused item.", "PASTE_SHORTCUT": "menu label - Contextual menu item that pastes the previously copied item.", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index f1705750d..d5cd502d7 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1661,6 +1661,48 @@ Blockly.Msg.ALT_KEY = 'Alt'; /// Representation of the Enter key used in keyboard shortcuts. Blockly.Msg.ENTER_KEY = 'Enter'; /** @type {string} */ +/// Representation of the Backspace key used in keyboard shortcuts. +Blockly.Msg.BACKSPACE_KEY = 'Backspace'; +/** @type {string} */ +/// Representation of the Delete key used in keyboard shortcuts. +Blockly.Msg.DELETE_KEY = 'Delete'; +/** @type {string} */ +/// Representation of the Escape key used in keyboard shortcuts. +Blockly.Msg.ESCAPE = 'Esc'; +/** @type {string} */ +/// Representation of the Tab key used in keyboard shortcuts. +Blockly.Msg.TAB_KEY = 'Tab'; +/** @type {string} */ +/// Representation of the Shift key used in keyboard shortcuts. +Blockly.Msg.SHIFT_KEY = 'Shift'; +/** @type {string} */ +/// Representation of the Caps Lock key used in keyboard shortcuts. +Blockly.Msg.CAPS_LOCK_KEY = 'Caps Lock'; +/** @type {string} */ +/// Representation of the Space key used in keyboard shortcuts. +Blockly.Msg.SPACE_KEY = 'Space'; +/** @type {string} */ +/// Representation of the Page Up key used in keyboard shortcuts. +Blockly.Msg.PAGE_UP_KEY = 'Page Up'; +/** @type {string} */ +/// Representation of the Page Down key used in keyboard shortcuts. +Blockly.Msg.PAGE_DOWN_KEY = 'Page Down'; +/** @type {string} */ +/// Representation of the End key used in keyboard shortcuts. +Blockly.Msg.END_KEY = 'End'; +/** @type {string} */ +/// Representation of the Home key used in keyboard shortcuts. +Blockly.Msg.HOME_KEY = 'Home'; +/** @type {string} */ +/// Representation of the Insert key used in keyboard shortcuts. +Blockly.Msg.INSERT_KEY = 'Insert'; +/** @type {string} */ +/// Representation of the Pause key used in keyboard shortcuts. +Blockly.Msg.PAUSE_KEY = 'Pause'; +/** @type {string} */ +/// Representation of the Context Menu key used in keyboard shortcuts. +Blockly.Msg.CONTEXT_MENU_KEY = '≣ Menu'; +/** @type {string} */ /// menu label - Contextual menu item that cuts the focused item. Blockly.Msg.CUT_SHORTCUT = 'Cut'; /** @type {string} */ From f899f6865492a1442bd302836a6815b710dada70 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 22 Apr 2026 08:25:17 -0700 Subject: [PATCH 061/200] fix: Don't close the flyout when creating a variable using keyboard nav (#9745) * feat: Use instead of built-in browser alerts * fix: Don't close the flyout when creating a variable using keyboard nav * test: Fix tests * fix: Take ephemeral focus for the dialog * fix: Remove unneeded focused node check --- packages/blockly/core/css.ts | 17 +++ packages/blockly/core/dialog.ts | 128 ++++++++++++++++-- packages/blockly/core/flyout_base.ts | 11 ++ packages/blockly/core/variables.ts | 28 +++- .../tests/mocha/contextmenu_items_test.js | 20 ++- packages/blockly/tests/mocha/dialog_test.js | 94 +++++++------ .../tests/mocha/test_helpers/workspace.js | 30 ++-- 7 files changed, 260 insertions(+), 68 deletions(-) diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index 5d853622e..c90581d02 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -8,6 +8,7 @@ /** Has CSS already been injected? */ const injectionSites = new WeakSet(); const registeredStyleSheets: Array = []; +import * as userAgent from './utils/useragent.js'; /** * Add some CSS to the blob that will be injected later. Allows optional @@ -659,4 +660,20 @@ input[type=number] { outline: var(--blockly-selection-width) solid var(--blockly-active-node-color); border-radius: 2px; } +.blocklyDialog { + min-width: 300px; + border-radius: 16px; + box-shadow: 0 8px 8px rgba(0, 0, 0, 0.2); + border: 1px solid #999; +} +.blocklyDialogForm { + display: flex; + flex-direction: column; + row-gap: 8px; +} +.blocklyDialogButtonRow { + display: flex; + flex-direction: ${userAgent.MOBILE || userAgent.APPLE ? 'row-reverse' : 'row'}; + column-gap: 8px; +} `; diff --git a/packages/blockly/core/dialog.ts b/packages/blockly/core/dialog.ts index 96631e9cb..ccb542669 100644 --- a/packages/blockly/core/dialog.ts +++ b/packages/blockly/core/dialog.ts @@ -6,15 +6,24 @@ // Former goog.module ID: Blockly.dialog +import {getFocusManager} from './focus_manager.js'; +import {Msg} from './msg.js'; import type {ToastOptions} from './toast.js'; import {Toast} from './toast.js'; import type {WorkspaceSvg} from './workspace_svg.js'; +/** Supported types of dialogs. */ +enum DialogType { + ALERT = 1, + CONFIRM, + PROMPT, +} + +/** Tally of the number of dialogs currently on screen. */ +let activeDialogCount = 0; + const defaultAlert = function (message: string, opt_callback?: () => void) { - window.alert(message); - if (opt_callback) { - opt_callback(); - } + displayDialog(DialogType.ALERT, message, opt_callback, undefined); }; let alertImplementation = defaultAlert; @@ -23,7 +32,7 @@ const defaultConfirm = function ( message: string, callback: (result: boolean) => void, ) { - callback(window.confirm(message)); + displayDialog(DialogType.CONFIRM, message, callback, undefined); }; let confirmImplementation = defaultConfirm; @@ -33,9 +42,7 @@ const defaultPrompt = function ( defaultValue: string, callback: (result: string | null) => void, ) { - // NOTE TO DEVELOPER: Ephemeral focus doesn't need to be taken for the native - // window prompt since it prevents focus from changing while open. - callback(window.prompt(message, defaultValue)); + displayDialog(DialogType.PROMPT, message, callback, defaultValue); }; let promptImplementation = defaultPrompt; @@ -165,3 +172,108 @@ export function setToast( ) { toastImplementation = toastFunction; } + +/** + * Displays a dialog, potentially prompting for user input, and invokes the + * provided callback with the response. + */ +function displayDialog( + ...[type, message, callback, defaultValue]: + | [ + type: DialogType.PROMPT, + message: string, + callback: (result: string | null) => void, + defaultValue: string | undefined, + ] + | [ + type: DialogType.CONFIRM, + message: string, + callback: (result: boolean) => void, + defaultValue: undefined, + ] + | [ + type: DialogType.ALERT, + message: string, + callback: (() => void) | undefined, + defaultValue: undefined, + ] +): void { + const OK = 'ok'; + const CANCEL = 'cancel'; + + const dialog = document.createElement('dialog'); + const form = document.createElement('form'); + const label = document.createElement('label'); + const input = document.createElement('input'); + const buttonRow = document.createElement('div'); + const ok = document.createElement('button'); + + dialog.className = 'blocklyDialog'; + form.className = 'blocklyDialogForm'; + label.className = 'blocklyDialogPrompt'; + buttonRow.className = 'blocklyDialogButtonRow'; + ok.className = 'blocklyDialogConfirmButton'; + + form.setAttribute('method', 'dialog'); + + label.textContent = message; + label.setAttribute('for', 'blockly-form-input'); + + ok.textContent = Msg['DIALOG_OK']; + ok.value = OK; + + dialog.appendChild(form); + form.appendChild(label); + + if (type === DialogType.PROMPT) { + input.id = 'blockly-form-input'; + input.className = 'blocklyDialogInput'; + input.type = 'text'; + input.name = 'input'; + input.autofocus = true; + if (defaultValue) { + input.value = defaultValue; + } + form.appendChild(input); + } + + buttonRow.appendChild(ok); + + if (type === DialogType.CONFIRM || type === DialogType.PROMPT) { + const cancel = document.createElement('button'); + cancel.className = 'blocklyDialogCancelButton'; + cancel.textContent = Msg['DIALOG_CANCEL']; + cancel.value = CANCEL; + buttonRow.appendChild(cancel); + } + + form.appendChild(buttonRow); + + let restoreFocus: (() => void) | undefined; + if (activeDialogCount === 0) { + restoreFocus = getFocusManager().takeEphemeralFocus(dialog); + } + + activeDialogCount++; + + dialog.addEventListener('close', () => { + activeDialogCount--; + if (!activeDialogCount) { + restoreFocus?.(); + } + dialog.remove(); + switch (type) { + case DialogType.CONFIRM: + callback(dialog.returnValue === OK); + break; + case DialogType.PROMPT: + callback(dialog.returnValue === OK ? input.value : null); + break; + case DialogType.ALERT: + callback?.(); + } + }); + document.body.appendChild(dialog); + + dialog.showModal(); +} diff --git a/packages/blockly/core/flyout_base.ts b/packages/blockly/core/flyout_base.ts index fb2ff01c2..3e0dbbee1 100644 --- a/packages/blockly/core/flyout_base.ts +++ b/packages/blockly/core/flyout_base.ts @@ -20,6 +20,7 @@ import * as eventUtils from './events/utils.js'; import {FlyoutItem} from './flyout_item.js'; import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js'; +import {getFocusManager} from './focus_manager.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; @@ -583,6 +584,16 @@ export abstract class Flyout * toolbox definition, or a string with the name of the dynamic category. */ show(flyoutDef: toolbox.FlyoutDefinition | string) { + // As part of showing, the existing contents of the flyout will be cleared. + // If the focused element is a flyout item, i.e. a child of the workspace + // and not the workspace itself, move focus to the workspace to prevent + // focus from being lost when the currently focused element is destroyed. + if ( + getFocusManager().getFocusedTree() === this.workspace_ && + getFocusManager().getFocusedNode() !== this.workspace_ + ) { + getFocusManager().focusNode(this.workspace_); + } this.workspace_.setResizesEnabled(false); eventUtils.setRecordUndo(false); this.hide(); diff --git a/packages/blockly/core/variables.ts b/packages/blockly/core/variables.ts index cbbd8843f..fc81ed5a6 100644 --- a/packages/blockly/core/variables.ts +++ b/packages/blockly/core/variables.ts @@ -7,8 +7,12 @@ // Former goog.module ID: Blockly.Variables import type {Block} from './block.js'; +import type {BlockSvg} from './block_svg.js'; import {Blocks} from './blocks.js'; import * as dialog from './dialog.js'; +import type {BlockCreate} from './events/events.js'; +import * as Events from './events/events.js'; +import {getFocusManager} from './focus_manager.js'; import {isLegacyProcedureDefBlock} from './interfaces/i_legacy_procedure_blocks.js'; import {isVariableBackedParameterModel} from './interfaces/i_variable_backed_parameter_model.js'; import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; @@ -403,7 +407,7 @@ export function generateUniqueNameFromOptions( * will default to '', which is a specific type. */ export function createVariableButtonHandler( - workspace: Workspace, + workspace: WorkspaceSvg, opt_callback?: (p1?: string | null) => void, opt_type?: string, ) { @@ -420,8 +424,28 @@ export function createVariableButtonHandler( const existing = nameUsedWithAnyType(text, workspace); if (!existing) { // No conflict - workspace.getVariableMap().createVariable(text, type); + const variable = workspace.getVariableMap().createVariable(text, type); if (opt_callback) opt_callback(text); + const flyoutWorkspace = workspace.getFlyout()?.getWorkspace(); + if (!flyoutWorkspace) return; + const changeListener = (e: Events.Abstract) => { + // Focus the newly created variable_set block. + if (e.type === Events.BLOCK_CREATE) { + const blockId = (e as BlockCreate).blockId; + if (blockId) { + const block = flyoutWorkspace.getBlockById(blockId); + if ( + block && + block.type === 'variables_set' && + block.getFieldValue('VAR') === variable.getId() + ) { + getFocusManager().focusNode(block as BlockSvg); + flyoutWorkspace.removeChangeListener(changeListener); + } + } + } + }; + flyoutWorkspace.addChangeListener(changeListener); return; } diff --git a/packages/blockly/tests/mocha/contextmenu_items_test.js b/packages/blockly/tests/mocha/contextmenu_items_test.js index 52f4428ba..a1be10fd8 100644 --- a/packages/blockly/tests/mocha/contextmenu_items_test.js +++ b/packages/blockly/tests/mocha/contextmenu_items_test.js @@ -318,30 +318,38 @@ suite('Context Menu Items', function () { test('Deletes all blocks after confirming', function () { // Mocks the confirmation dialog and calls the callback with 'true' simulating ok. - const confirmStub = sinon.stub(window, 'confirm').returns(true); + let callCount = 0; + Blockly.dialog.setConfirm((_message, callback) => { + callCount++; + callback(true); + }); this.workspace.newBlock('text'); this.workspace.newBlock('text'); this.deleteOption.callback(this.scope); this.clock.runAll(); - sinon.assert.calledOnce(confirmStub); + assert.equal(callCount, 1); assert.equal(this.workspace.getTopBlocks(false).length, 0); - confirmStub.restore(); + Blockly.dialog.setConfirm(); }); test('Does not delete blocks if not confirmed', function () { // Mocks the confirmation dialog and calls the callback with 'false' simulating cancel. - const confirmStub = sinon.stub(window, 'confirm').returns(false); + let callCount = 0; + Blockly.dialog.setConfirm((_message, callback) => { + callCount++; + callback(false); + }); this.workspace.newBlock('text'); this.workspace.newBlock('text'); this.deleteOption.callback(this.scope); this.clock.runAll(); - sinon.assert.calledOnce(confirmStub); + assert.equal(callCount, 1); assert.equal(this.workspace.getTopBlocks(false).length, 2); - confirmStub.restore(); + Blockly.dialog.setConfirm(); }); test('No dialog for single block', function () { diff --git a/packages/blockly/tests/mocha/dialog_test.js b/packages/blockly/tests/mocha/dialog_test.js index 7d4147d83..ef8e17cf7 100644 --- a/packages/blockly/tests/mocha/dialog_test.js +++ b/packages/blockly/tests/mocha/dialog_test.js @@ -24,11 +24,15 @@ suite('Dialog utilities', function () { Blockly.dialog.setToast(); }); - test('use the browser alert by default', function () { - const alert = sinon.stub(window, 'alert'); - Blockly.dialog.alert('test'); - assert.isTrue(alert.calledWith('test')); - alert.restore(); + test('use the built in alert by default', function (done) { + const callback = () => { + done(); + }; + const message = 'test'; + Blockly.dialog.alert(message, callback); + const dialog = document.querySelector('dialog'); + assert.include(dialog.textContent, 'test'); + dialog.querySelector('.blocklyDialogConfirmButton').click(); }); test('support setting a custom alert handler', function () { @@ -40,24 +44,23 @@ suite('Dialog utilities', function () { assert.isTrue(alert.calledWith('test', callback)); }); - test('do not call the browser alert if a custom alert handler is set', function () { - const browserAlert = sinon.stub(window, 'alert'); - + test('do not call the built in alert if a custom alert handler is set', function () { const alert = sinon.spy(); Blockly.dialog.setAlert(alert); Blockly.dialog.alert(test); - assert.isFalse(browserAlert.called); - - browserAlert.restore(); + const dialog = document.querySelector('dialog'); + assert.isNull(dialog); }); - test('use the browser confirm by default', function () { - const confirm = sinon.stub(window, 'confirm'); - const callback = () => {}; + test('use the built in confirm by default', function (done) { + const callback = () => { + done(); + }; const message = 'test'; Blockly.dialog.confirm(message, callback); - assert.isTrue(confirm.calledWith(message)); - confirm.restore(); + const dialog = document.querySelector('dialog'); + assert.include(dialog.textContent, 'test'); + dialog.querySelector('.blocklyDialogCancelButton').click(); }); test('support setting a custom confirm handler', function () { @@ -69,36 +72,39 @@ suite('Dialog utilities', function () { assert.isTrue(confirm.calledWith('test', callback)); }); - test('do not call the browser confirm if a custom confirm handler is set', function () { - const browserConfirm = sinon.stub(window, 'confirm'); - + test('do not call the built in confirm if a custom confirm handler is set', function () { const confirm = sinon.spy(); Blockly.dialog.setConfirm(confirm); const callback = () => {}; const message = 'test'; Blockly.dialog.confirm(message, callback); - assert.isFalse(browserConfirm.called); - - browserConfirm.restore(); + const dialog = document.querySelector('dialog'); + assert.isNull(dialog); }); - test('invokes the provided callback with the confirmation response', function () { - const confirm = sinon.stub(window, 'confirm').returns(true); - const callback = sinon.spy(); + test('invokes the provided callback with the confirmation response', function (done) { + const callback = (result) => { + assert.isTrue(result); + done(); + }; const message = 'test'; Blockly.dialog.confirm(message, callback); - assert.isTrue(callback.calledWith(true)); - confirm.restore(); + const dialog = document.querySelector('dialog'); + dialog.querySelector('.blocklyDialogConfirmButton').click(); }); - test('use the browser prompt by default', function () { - const prompt = sinon.stub(window, 'prompt'); - const callback = () => {}; + test('use the built in prompt by default', function (done) { + const callback = () => { + done(); + }; const message = 'test'; const defaultValue = 'default'; Blockly.dialog.prompt(message, defaultValue, callback); - assert.isTrue(prompt.calledWith(message, defaultValue)); - prompt.restore(); + const dialog = document.querySelector('dialog'); + assert.include(dialog.textContent, 'test'); + const input = dialog.querySelector('input'); + assert.equal(input.value, 'default'); + dialog.querySelector('.blocklyDialogCancelButton').click(); }); test('support setting a custom prompt handler', function () { @@ -111,28 +117,30 @@ suite('Dialog utilities', function () { assert.isTrue(prompt.calledWith('test', defaultValue, callback)); }); - test('do not call the browser prompt if a custom prompt handler is set', function () { - const browserPrompt = sinon.stub(window, 'prompt'); - + test('do not call the built in prompt if a custom prompt handler is set', function () { const prompt = sinon.spy(); Blockly.dialog.setPrompt(prompt); const callback = () => {}; const message = 'test'; const defaultValue = 'default'; Blockly.dialog.prompt(message, defaultValue, callback); - assert.isFalse(browserPrompt.called); - - browserPrompt.restore(); + const dialog = document.querySelector('dialog'); + assert.isNull(dialog); }); - test('invokes the provided callback with the prompt response', function () { - const prompt = sinon.stub(window, 'prompt').returns('something'); - const callback = sinon.spy(); + test('invokes the provided callback with the prompt response', function (done) { + const callback = (response) => { + assert.equal(response, 'something'); + done(); + }; const message = 'test'; const defaultValue = 'default'; Blockly.dialog.prompt(message, defaultValue, callback); - assert.isTrue(callback.calledWith('something')); - prompt.restore(); + const dialog = document.querySelector('dialog'); + assert.include(dialog.textContent, 'test'); + const input = dialog.querySelector('input'); + input.value = 'something'; + dialog.querySelector('.blocklyDialogConfirmButton').click(); }); test('use the built-in toast by default', function () { diff --git a/packages/blockly/tests/mocha/test_helpers/workspace.js b/packages/blockly/tests/mocha/test_helpers/workspace.js index 9a1633da5..fbd08a49f 100644 --- a/packages/blockly/tests/mocha/test_helpers/workspace.js +++ b/packages/blockly/tests/mocha/test_helpers/workspace.js @@ -100,48 +100,60 @@ export function testAWorkspace() { test('deleteVariableById(id2) one usage', function () { // Deleting variable one usage should not trigger confirm dialog. - const stub = sinon.stub(window, 'confirm').returns(true); + let callCount = 0; + Blockly.dialog.setConfirm((_message, callback) => { + callCount++; + callback(true); + }); const id2 = this.variableMap.getVariableById('id2'); Blockly.Variables.deleteVariable(this.workspace, id2); - sinon.assert.notCalled(stub); + assert.equal(callCount, 0); const variable = this.variableMap.getVariableById('id2'); assert.isNull(variable); assertVariableValues(this.variableMap, 'name1', 'type1', 'id1'); assertBlockVarModelName(this.workspace, 0, 'name1'); - stub.restore(); + Blockly.dialog.setConfirm(); }); test('deleteVariableById(id1) multiple usages confirm', function () { // Deleting variable with multiple usages triggers confirm dialog. - const stub = sinon.stub(window, 'confirm').returns(true); + let callCount = 0; + Blockly.dialog.setConfirm((_message, callback) => { + callCount++; + callback(true); + }); const id1 = this.variableMap.getVariableById('id1'); Blockly.Variables.deleteVariable(this.workspace, id1); - sinon.assert.calledOnce(stub); + assert.equal(callCount, 1); const variable = this.variableMap.getVariableById('id1'); assert.isNull(variable); assertVariableValues(this.variableMap, 'name2', 'type2', 'id2'); assertBlockVarModelName(this.workspace, 0, 'name2'); - stub.restore(); + Blockly.dialog.setConfirm(); }); test('deleteVariableById(id1) multiple usages cancel', function () { // Deleting variable with multiple usages triggers confirm dialog. - const stub = sinon.stub(window, 'confirm').returns(false); + let callCount = 0; + Blockly.dialog.setConfirm((_message, callback) => { + callCount++; + callback(false); + }); const id1 = this.variableMap.getVariableById('id1'); Blockly.Variables.deleteVariable(this.workspace, id1); - sinon.assert.calledOnce(stub); + assert.equal(callCount, 1); assertVariableValues(this.variableMap, 'name1', 'type1', 'id1'); assertVariableValues(this.variableMap, 'name2', 'type2', 'id2'); assertBlockVarModelName(this.workspace, 0, 'name1'); assertBlockVarModelName(this.workspace, 1, 'name1'); assertBlockVarModelName(this.workspace, 2, 'name2'); - stub.restore(); + Blockly.dialog.setConfirm(); }); }); From 44d8554622542790be54d267d808fc080ba8275c Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 22 Apr 2026 15:49:27 -0400 Subject: [PATCH 062/200] fix: set role application and fix block readouts (#9747) * fix: set role application and fix block readouts * fix: fix tests --- packages/blockly/core/block_aria_composer.ts | 6 ++-- packages/blockly/core/block_svg.ts | 6 +++- packages/blockly/core/inject.ts | 1 + packages/blockly/tests/mocha/aria_test.js | 36 ++++++++++---------- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 256a68534..e578154d3 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -79,7 +79,9 @@ export function computeAriaLabel( * @param block The block to set ARIA role and roledescription attributes on. */ export function configureAriaRole(block: BlockSvg) { - setRole(block.getSvgRoot(), block.isInFlyout ? Role.LISTITEM : Role.FIGURE); + setRole(block.getSvgRoot(), Role.PRESENTATION); + const focusableElement = block.getFocusableElement(); + setRole(focusableElement, block.isInFlyout ? Role.LISTITEM : Role.FIGURE); let roleDescription = Msg['BLOCK_LABEL_STATEMENT']; if (block.statementInputCount) { @@ -88,7 +90,7 @@ export function configureAriaRole(block: BlockSvg) { roleDescription = Msg['BLOCK_LABEL_VALUE']; } - setState(block.getSvgRoot(), State.ROLEDESCRIPTION, roleDescription); + setState(focusableElement, State.ROLEDESCRIPTION, roleDescription); } /** diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 3b4aac5c1..e9321754e 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -1998,7 +1998,11 @@ export class BlockSvg * Updates the ARIA label, role and roledescription for this block. */ private recomputeAriaAttributes() { - aria.setState(this.getSvgRoot(), aria.State.LABEL, computeAriaLabel(this)); + aria.setState( + this.getFocusableElement(), + aria.State.LABEL, + computeAriaLabel(this), + ); configureAriaRole(this); } diff --git a/packages/blockly/core/inject.ts b/packages/blockly/core/inject.ts index 8cbae9b61..55cad0590 100644 --- a/packages/blockly/core/inject.ts +++ b/packages/blockly/core/inject.ts @@ -54,6 +54,7 @@ export function inject( if (opt_options?.rtl) { dom.addClass(subContainer, 'blocklyRTL'); } + aria.setRole(subContainer, aria.Role.APPLICATION); containerElement!.appendChild(subContainer); const svg = createDom(subContainer, options); diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 3fc959753..91227ff4c 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -292,7 +292,7 @@ suite('ARIA', function () { test('Statement blocks have correct role description', function () { const block = this.makeBlock('text_print'); const roleDescription = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.ROLEDESCRIPTION, ); assert.equal(roleDescription, 'statement'); @@ -301,7 +301,7 @@ suite('ARIA', function () { test('Value blocks have correct role description', function () { const block = this.makeBlock('logic_boolean'); const roleDescription = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.ROLEDESCRIPTION, ); assert.equal(roleDescription, 'value'); @@ -310,7 +310,7 @@ suite('ARIA', function () { test('Container blocks have correct role description', function () { const block = this.makeBlock('controls_if'); const roleDescription = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.ROLEDESCRIPTION, ); assert.equal(roleDescription, 'container'); @@ -318,7 +318,7 @@ suite('ARIA', function () { test('Workspace blocks have the correct role', function () { const block = this.makeBlock('text_print'); - const role = Blockly.utils.aria.getRole(block.getSvgRoot()); + const role = Blockly.utils.aria.getRole(block.getFocusableElement()); assert.equal(role, Blockly.utils.aria.Role.FIGURE); }); @@ -327,14 +327,14 @@ suite('ARIA', function () { this.workspace.getToolbox().getToolboxItems()[0], ); const block = this.workspace.getFlyout().getWorkspace().getTopBlocks()[0]; - const role = Blockly.utils.aria.getRole(block.getSvgRoot()); + const role = Blockly.utils.aria.getRole(block.getFocusableElement()); assert.equal(role, Blockly.utils.aria.Role.LISTITEM); }); test('Root workspace blocks indicate that in their labels', function () { const block = this.makeBlock('text_print'); const label = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.isTrue(label.startsWith('Begin stack')); @@ -346,7 +346,7 @@ suite('ARIA', function () { ); const block = this.workspace.getFlyout().getWorkspace().getTopBlocks()[0]; const label = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.notInclude(label, 'Begin stack'); @@ -357,7 +357,7 @@ suite('ARIA', function () { const printBlock = this.makeBlock('text_print'); ifBlock.getInput('IF0').connection.connect(printBlock.previousConnection); const label = Blockly.utils.aria.getState( - printBlock.getSvgRoot(), + printBlock.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.isFalse(label.startsWith('Begin do')); @@ -370,7 +370,7 @@ suite('ARIA', function () { .getInput('ELSE') .connection.connect(printBlock.previousConnection); const label = Blockly.utils.aria.getState( - printBlock.getSvgRoot(), + printBlock.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.isTrue(label.startsWith('Begin else')); @@ -379,13 +379,13 @@ suite('ARIA', function () { test('Disabled blocks indicate that in their label', function () { const block = this.makeBlock('text_print'); let label = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.notInclude(label, 'disabled'); block.setDisabledReason(true, 'testing'); label = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.include(label, 'disabled'); @@ -394,13 +394,13 @@ suite('ARIA', function () { test('Collapsed blocks indicate that in their label', function () { const block = this.makeBlock('text_print'); let label = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.notInclude(label, 'collapsed'); block.setCollapsed(true); label = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.include(label, 'collapsed'); @@ -411,13 +411,13 @@ suite('ARIA', function () { const text = this.makeBlock('text'); text.outputConnection.connect(block.inputList[0].connection); let label = Blockly.utils.aria.getState( - text.getSvgRoot(), + text.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.notInclude(label, 'replaceable'); text.setShadow(true); label = Blockly.utils.aria.getState( - text.getSvgRoot(), + text.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.include(label, 'replaceable'); @@ -426,7 +426,7 @@ suite('ARIA', function () { test('Blocks without inputs are properly labeled', function () { const block = this.makeBlock('math_random_float'); const label = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.notInclude(label, 'input'); @@ -435,7 +435,7 @@ suite('ARIA', function () { test('Blocks with one input are properly labeled', function () { const block = this.makeBlock('logic_negate'); const label = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.isTrue(label.endsWith('has input')); @@ -444,7 +444,7 @@ suite('ARIA', function () { test('Blocks with multiple inputs are properly labeled', function () { const block = this.makeBlock('logic_ternary'); const label = Blockly.utils.aria.getState( - block.getSvgRoot(), + block.getFocusableElement(), Blockly.utils.aria.State.LABEL, ); assert.isTrue(label.endsWith('has inputs')); From 10739f9241873a55df3cae774072d1e6435c0b46 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 22 Apr 2026 17:10:37 -0400 Subject: [PATCH 063/200] fix: dont activate base delete areas for keyboard moves (#9748) * fix: dont activate base delete areas for keyboard moves * fix: add tests --- packages/blockly/core/delete_area.ts | 6 +++- packages/blockly/core/toolbox/toolbox.ts | 29 +++++-------------- packages/blockly/core/trashcan.ts | 7 +++++ packages/blockly/tests/mocha/toolbox_test.js | 29 ++++++++++++++++++- packages/blockly/tests/mocha/trashcan_test.js | 23 +++++++++++++++ 5 files changed, 70 insertions(+), 24 deletions(-) diff --git a/packages/blockly/core/delete_area.ts b/packages/blockly/core/delete_area.ts index 405084db9..c53acb195 100644 --- a/packages/blockly/core/delete_area.ts +++ b/packages/blockly/core/delete_area.ts @@ -17,6 +17,7 @@ import {DragTarget} from './drag_target.js'; import {isDeletable} from './interfaces/i_deletable.js'; import type {IDeleteArea} from './interfaces/i_delete_area.js'; import type {IDraggable} from './interfaces/i_draggable.js'; +import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; /** * Abstract class for a component that can delete a block or bubble that is @@ -56,7 +57,10 @@ export class DeleteArea extends DragTarget implements IDeleteArea { * area. */ wouldDelete(element: IDraggable): boolean { - if (element instanceof BlockSvg) { + // don't delete things if we're doing a keyboard move + if (KeyboardMover.mover.isMoving()) { + this.updateWouldDelete_(false); + } else if (element instanceof BlockSvg) { const block = element; const couldDeleteBlock = !block.getParent() && block.isDeletable(); this.updateWouldDelete_(couldDeleteBlock); diff --git a/packages/blockly/core/toolbox/toolbox.ts b/packages/blockly/core/toolbox/toolbox.ts index 67bb0fc84..c77f32a86 100644 --- a/packages/blockly/core/toolbox/toolbox.ts +++ b/packages/blockly/core/toolbox/toolbox.ts @@ -11,7 +11,6 @@ */ // Former goog.module ID: Blockly.Toolbox -import {BlockSvg} from '../block_svg.js'; import * as browserEvents from '../browser_events.js'; import * as common from '../common.js'; import {ComponentManager} from '../component_manager.js'; @@ -26,7 +25,6 @@ import { isCollapsibleToolboxItem, type ICollapsibleToolboxItem, } from '../interfaces/i_collapsible_toolbox_item.js'; -import {isDeletable} from '../interfaces/i_deletable.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; import type {IFlyout} from '../interfaces/i_flyout.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; @@ -37,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 {KeyboardMover} from '../keyboard_nav/keyboard_mover.js'; import {ToolboxNavigator} from '../keyboard_nav/navigators/toolbox_navigator.js'; import * as registry from '../registry.js'; import type {KeyboardShortcut} from '../shortcut_registry.js'; @@ -508,32 +507,14 @@ export class Toolbox } } - /** - * Returns whether the provided block or bubble would be deleted if dropped on - * this area. - * This method should check if the element is deletable and is always called - * before onDragEnter/onDragOver/onDragExit. - * - * @param element The block or bubble currently being dragged. - * @returns Whether the element provided would be deleted if dropped on this - * area. - */ - override wouldDelete(element: IDraggable): boolean { - if (element instanceof BlockSvg) { - const block = element; - this.updateWouldDelete_(!block.getParent() && block.isDeletable()); - } else { - this.updateWouldDelete_(isDeletable(element) && element.isDeletable()); - } - return this.wouldDelete_; - } - /** * Handles when a cursor with a block or bubble enters this drag target. * * @param _dragElement The block or bubble currently being dragged. */ override onDragEnter(_dragElement: IDraggable) { + // don't trigger for keyboard moves + if (KeyboardMover.mover.isMoving()) return; this.updateCursorDeleteStyle_(true); } @@ -543,6 +524,8 @@ export class Toolbox * @param _dragElement The block or bubble currently being dragged. */ override onDragExit(_dragElement: IDraggable) { + // don't trigger for keyboard moves + if (KeyboardMover.mover.isMoving()) return; this.updateCursorDeleteStyle_(false); } @@ -553,6 +536,8 @@ export class Toolbox * @param _dragElement The block or bubble currently being dragged. */ override onDrop(_dragElement: IDraggable) { + // don't trigger for keyboard moves + if (KeyboardMover.mover.isMoving()) return; this.updateCursorDeleteStyle_(false); } diff --git a/packages/blockly/core/trashcan.ts b/packages/blockly/core/trashcan.ts index 08211bac9..3f7509239 100644 --- a/packages/blockly/core/trashcan.ts +++ b/packages/blockly/core/trashcan.ts @@ -24,6 +24,7 @@ import type {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IDraggable} from './interfaces/i_draggable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IPositionable} from './interfaces/i_positionable.js'; +import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import type {UiMetrics} from './metrics_manager.js'; import * as uiPosition from './positionable_helpers.js'; import * as registry from './registry.js'; @@ -426,6 +427,8 @@ export class Trashcan * @param _dragElement The block or bubble currently being dragged. */ override onDragOver(_dragElement: IDraggable) { + // don't trigger for keyboard moves + if (KeyboardMover.mover.isMoving()) return; this.setLidOpen(this.wouldDelete_); } @@ -435,6 +438,8 @@ export class Trashcan * @param _dragElement The block or bubble currently being dragged. */ override onDragExit(_dragElement: IDraggable) { + // don't trigger for keyboard moves + if (KeyboardMover.mover.isMoving()) return; this.setLidOpen(false); } @@ -445,6 +450,8 @@ export class Trashcan * @param _dragElement The block or bubble currently being dragged. */ override onDrop(_dragElement: IDraggable) { + // don't trigger for keyboard moves + if (KeyboardMover.mover.isMoving()) return; setTimeout(this.setLidOpen.bind(this, false), 100); } diff --git a/packages/blockly/tests/mocha/toolbox_test.js b/packages/blockly/tests/mocha/toolbox_test.js index 5886fe7f6..c02f09f4d 100644 --- a/packages/blockly/tests/mocha/toolbox_test.js +++ b/packages/blockly/tests/mocha/toolbox_test.js @@ -5,7 +5,10 @@ */ import {assert} from '../../node_modules/chai/index.js'; -import {defineStackBlock} from './test_helpers/block_definitions.js'; +import { + defineBasicBlockWithField, + defineStackBlock, +} from './test_helpers/block_definitions.js'; import { sharedTestSetup, sharedTestTeardown, @@ -812,4 +815,28 @@ suite('Toolbox', function () { ); }); }); + suite('delete area', function () { + test('Keyboard drag - wouldDelete returns false', function () { + // Create a deletable block + defineBasicBlockWithField(); + const block = this.toolbox.getWorkspace().newBlock('test_field_block'); + block.initSvg(); + block.render(); + + // Stub KeyboardMover.mover.isMoving() to return true + const isMovingStub = sinon + .stub(Blockly.KeyboardMover.mover, 'isMoving') + .returns(true); + + try { + const result = this.toolbox.wouldDelete(block); + assert.isFalse( + result, + 'wouldDelete should return false during keyboard move', + ); + } finally { + isMovingStub.restore(); + } + }); + }); }); diff --git a/packages/blockly/tests/mocha/trashcan_test.js b/packages/blockly/tests/mocha/trashcan_test.js index d96e00f3a..62486bb5f 100644 --- a/packages/blockly/tests/mocha/trashcan_test.js +++ b/packages/blockly/tests/mocha/trashcan_test.js @@ -373,4 +373,27 @@ suite('Trashcan', function () { this.workspace.options.maxTrashcanContents = Infinity; }); }); + suite('delete area', function () { + test('Keyboard drag - wouldDelete returns false', function () { + // Create a deletable block + const block = this.workspace.newBlock('test_field_block'); + block.initSvg(); + block.render(); + + // Stub KeyboardMover.mover.isMoving() to return true + const isMovingStub = sinon + .stub(Blockly.KeyboardMover.mover, 'isMoving') + .returns(true); + + try { + const result = this.trashcan.wouldDelete(block); + assert.isFalse( + result, + 'wouldDelete should return false during keyboard move', + ); + } finally { + isMovingStub.restore(); + } + }); + }); }); From 1d159830e4085ea5bc2bad58887101c21b755443 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 23 Apr 2026 11:35:32 -0700 Subject: [PATCH 064/200] fix: Improve focus handling when clicking outside injection div (#9749) * fix: Improve focus handling when clicking outside injection div * chore: Use 'popover' in place of 'quasimodal' * chore: Clarify docs --- packages/blockly/core/common.ts | 5 ++ packages/blockly/core/dropdowndiv.ts | 16 +++++ packages/blockly/core/focus_manager.ts | 84 +++++++++++++++++++++++-- packages/blockly/core/shortcut_items.ts | 3 +- packages/blockly/core/widgetdiv.ts | 15 +++++ 5 files changed, 117 insertions(+), 6 deletions(-) diff --git a/packages/blockly/core/common.ts b/packages/blockly/core/common.ts index a87e1aa12..87fb19f71 100644 --- a/packages/blockly/core/common.ts +++ b/packages/blockly/core/common.ts @@ -86,6 +86,11 @@ export function getMainWorkspace(): Workspace { */ export function setMainWorkspace(workspace: Workspace) { mainWorkspace = workspace; + if (workspace.rendered) { + getFocusManager().setPopoverFocusRoot( + (workspace as WorkspaceSvg).getInjectionDiv(), + ); + } } /** diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index 75abc7204..ceacf9faf 100644 --- a/packages/blockly/core/dropdowndiv.ts +++ b/packages/blockly/core/dropdowndiv.ts @@ -151,6 +151,19 @@ export function createDom() { 'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's'; } +/** + * Deals with the root element that contains this and other popovers losing + * focus by returning ephemeral focus if we hold it and hiding the DropDownDiv. + */ +function handleFocusLoss() { + if (returnEphemeralFocus) { + returnEphemeralFocus(false); + returnEphemeralFocus = null; + } + + hide(); +} + /** * Set an element to maintain bounds within. Drop-downs will appear * within the box of this element if possible. @@ -370,6 +383,8 @@ export function show( manageEphemeralFocus: boolean, opt_onHide?: () => void, ): boolean { + getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss); + const parentDiv = common.getParentContainer(); parentDiv?.appendChild(div); @@ -669,6 +684,7 @@ export function hideIfOwner( /** Hide the menu, triggering animation. */ export function hide() { + getFocusManager().unregisterPopoverFocusLossHandler(handleFocusLoss); // Start the animation by setting the translation and fading out. // Reset to (initialX, initialY) - i.e., no translation. div.style.transform = 'translate(0, 0)'; diff --git a/packages/blockly/core/focus_manager.ts b/packages/blockly/core/focus_manager.ts index 47e432454..052006abf 100644 --- a/packages/blockly/core/focus_manager.ts +++ b/packages/blockly/core/focus_manager.ts @@ -11,11 +11,15 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; /** * Type declaration for returning focus to FocusManager upon completing an - * ephemeral UI flow (such as a dialog). + * ephemeral UI flow (such as a dialog). Normally, the FocusManager will refocus + * the previously-focused element. If callers do not wish for the FocusManager + * to do so, they may call this method with `restoreFocus` set to false to + * prevent automatic refocusing and leave focus where it is. + * * * See FocusManager.takeEphemeralFocus for more details. */ -export type ReturnEphemeralFocus = () => void; +export type ReturnEphemeralFocus = (restoreFocus?: boolean) => void; /** * Represents an IFocusableTree that has been registered for focus management in @@ -83,6 +87,33 @@ export class FocusManager { private recentlyLostAllFocus: boolean = false; private isUpdatingFocusedNode: boolean = false; + /** + * Root element in which popovers (WidgetDiv, DropDownDiv) currently live. + */ + private popoverFocusRoot?: HTMLElement; + + /** + * Set of callbacks to invoke if the popover focus root loses focus. + */ + private popoverFocusLossHandlers: Set<() => void> = new Set(); + + /** + * Handler for focusout in the popover focus root that selectively + * invokes the popover focus loss handlers if focus has truly transitioned + * outside of the focus root, and not e.g. to a different popover. + */ + private popoverFocusOutHandler = (e: FocusEvent) => { + const target = e.relatedTarget; + if ( + target === null || + (target instanceof Node && !this.popoverFocusRoot?.contains(target)) + ) { + for (const handler of this.popoverFocusLossHandlers) { + handler(); + } + } + }; + constructor( addGlobalEventListener: (type: string, listener: EventListener) => void, ) { @@ -446,7 +477,7 @@ export class FocusManager { focusableElement.focus({preventScroll: true}); let hasFinishedEphemeralFocus = false; - return () => { + return (restoreFocus = true) => { if (hasFinishedEphemeralFocus) { throw Error( `Attempted to finish ephemeral focus twice for element: ` + @@ -455,8 +486,7 @@ export class FocusManager { } hasFinishedEphemeralFocus = true; this.currentlyHoldsEphemeralFocus = false; - - if (this.focusedNode) { + if (this.focusedNode && restoreFocus) { this.activelyFocusNode(this.focusedNode, null); // Even though focus was restored, check if it's lost again. It's @@ -667,6 +697,50 @@ export class FocusManager { } return FocusManager.focusManager; } + + /** + * Sets the current popover focus root. Generally this is active + * workspace's injection div or the explicitly specified parent container for + * the WidgetDiv, DropDownDiv, etc. + * + * @internal + * @param newRoot The new element that contains all popovers. + */ + setPopoverFocusRoot(newRoot: HTMLElement) { + this.popoverFocusRoot?.removeEventListener( + 'focusout', + this.popoverFocusOutHandler, + ); + this.popoverFocusRoot = newRoot; + this.popoverFocusRoot.addEventListener( + 'focusout', + this.popoverFocusOutHandler, + ); + } + + /** + * Registers a callback to be invoked if the popover focus root loses + * focus. This should only be called by popovers that need to react to + * focus changes by e.g. hiding themselves and resigning ephemeral focus. + * + * @internal + * @param handler A callback function. + */ + registerPopoverFocusLossHandler(handler: () => void) { + this.popoverFocusLossHandlers.add(handler); + } + + /** + * Unregisters a previously-registered popover focus loss handler. This + * should only be invoked by popovers when they no longer need to be + * notified of focus loss, typically when they are hidden. + * + * @internal + * @param handler A previously-registered callback function. + */ + unregisterPopoverFocusLossHandler(handler: () => void) { + this.popoverFocusLossHandlers.delete(handler); + } } /** Convenience function for FocusManager.getFocusManager. */ diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 2b8d00d97..a4e3f12fe 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -904,7 +904,8 @@ export function registerPerformAction() { preconditionFn: (workspace) => !workspace.isDragging() && !dropDownDiv.isVisible() && - !widgetDiv.isVisible(), + !widgetDiv.isVisible() && + !getFocusManager().ephemeralFocusTaken(), callback: (_workspace, e) => { keyboardNavigationController.setIsActive(true); const focusedNode = getFocusManager().getFocusedNode(); diff --git a/packages/blockly/core/widgetdiv.ts b/packages/blockly/core/widgetdiv.ts index b8d04654c..d9d49a29d 100644 --- a/packages/blockly/core/widgetdiv.ts +++ b/packages/blockly/core/widgetdiv.ts @@ -61,6 +61,19 @@ export function testOnly_setDiv(newDiv: HTMLDivElement | null) { } } +/** + * Deals with the root element that contains this and other popovers losing + * focus by returning ephemeral focus if we hold it and hiding the WidgetDiv. + */ +function handleFocusLoss() { + if (returnEphemeralFocus) { + returnEphemeralFocus(false); + returnEphemeralFocus = null; + } + + hide(); +} + /** * Create the widget div and inject it onto the page. */ @@ -137,6 +150,7 @@ export function show( if (manageEphemeralFocus) { returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); } + getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss); } /** @@ -150,6 +164,7 @@ export function hide() { const div = containerDiv; if (!div) return; + getFocusManager().unregisterPopoverFocusLossHandler(handleFocusLoss); div.style.display = 'none'; div.style.left = ''; div.style.top = ''; From 8879f63fb3b3cb7390facfe543df0a6942378ee2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:28:39 +0000 Subject: [PATCH 065/200] release: v13.0.0-beta.1 --- package-lock.json | 6 +++--- packages/blockly/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0d77d384..0468868e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockly-repo", - "version": "0.0.1", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockly-repo", - "version": "0.0.1", + "version": "0.0.0", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -1950,7 +1950,7 @@ } }, "packages/blockly": { - "version": "13.0.0-beta.0", + "version": "13.0.0-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "devDependencies": { diff --git a/packages/blockly/package.json b/packages/blockly/package.json index e6f9fe86c..d1c74f688 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -1,6 +1,6 @@ { "name": "blockly", - "version": "13.0.0-beta.0", + "version": "13.0.0-beta.1", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" From 3a14fcc6d379e28f5402801b9137921346da2ff8 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 24 Apr 2026 09:26:53 -0700 Subject: [PATCH 066/200] fix: Fix bug that could cause focus to be lost in Chrome when dismissing a dropdown (#9763) --- packages/blockly/core/dropdowndiv.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index ceacf9faf..95b6c66e1 100644 --- a/packages/blockly/core/dropdowndiv.ts +++ b/packages/blockly/core/dropdowndiv.ts @@ -712,7 +712,6 @@ export function hideWithoutAnimation() { onHide(); onHide = null; } - clearContent(); owner = null; (common.getMainWorkspace() as WorkspaceSvg).markFocused(); @@ -721,6 +720,8 @@ export function hideWithoutAnimation() { returnEphemeralFocus(); returnEphemeralFocus = null; } + + clearContent(); } /** From bce825dacae7ad9e17f27651b801562781bb86bf Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 24 Apr 2026 09:27:12 -0700 Subject: [PATCH 067/200] fix: Don't focus newly created variable blocks when not using keyboard navigation (#9764) --- packages/blockly/core/variables.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/variables.ts b/packages/blockly/core/variables.ts index fc81ed5a6..f0015cc60 100644 --- a/packages/blockly/core/variables.ts +++ b/packages/blockly/core/variables.ts @@ -16,6 +16,7 @@ import {getFocusManager} from './focus_manager.js'; import {isLegacyProcedureDefBlock} from './interfaces/i_legacy_procedure_blocks.js'; import {isVariableBackedParameterModel} from './interfaces/i_variable_backed_parameter_model.js'; import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; +import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import {Msg} from './msg.js'; import * as deprecation from './utils/deprecation.js'; import type {BlockInfo, FlyoutItemInfo} from './utils/toolbox.js'; @@ -427,7 +428,9 @@ export function createVariableButtonHandler( const variable = workspace.getVariableMap().createVariable(text, type); if (opt_callback) opt_callback(text); const flyoutWorkspace = workspace.getFlyout()?.getWorkspace(); - if (!flyoutWorkspace) return; + if (!flyoutWorkspace || !keyboardNavigationController.getIsActive()) { + return; + } const changeListener = (e: Events.Abstract) => { // Focus the newly created variable_set block. if (e.type === Events.BLOCK_CREATE) { From c211a8955bb021ece9951ebb3872f82a31605c22 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 24 Apr 2026 09:28:05 -0700 Subject: [PATCH 068/200] fix: Disable some shortcuts in the flyout (#9765) --- packages/blockly/core/shortcut_items.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index a4e3f12fe..6b533e8bc 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -934,6 +934,7 @@ export function registerDuplicate() { return ( !workspace.isDragging() && !workspace.isReadOnly() && + !workspace.isFlyout && (focusedNode instanceof BlockSvg ? focusedNode.isDuplicatable() : true) ); }, @@ -959,7 +960,7 @@ export function registerCleanup() { const cleanupShortcut: KeyboardShortcut = { name: names.CLEANUP, preconditionFn: (workspace) => - !workspace.isDragging() && !workspace.isReadOnly(), + !workspace.isDragging() && !workspace.isReadOnly() && !workspace.isFlyout, callback: (workspace) => { keyboardNavigationController.setIsActive(true); workspace.cleanUp(); From 6d2a62ceb51915cb0ed2c403d531da28d2d90bb9 Mon Sep 17 00:00:00 2001 From: lizschwab Date: Fri, 24 Apr 2026 15:56:41 -0700 Subject: [PATCH 069/200] feat: Added keyboard shortcut for displaying tooltip (#9755) * feat: Added keyboard shortcut for displaying tooltip * fix for tooltips not appearing near the focused block --- packages/blockly/core/shortcut_items.ts | 29 +++++++++++ packages/blockly/core/tooltip.ts | 36 +++++++++++++ packages/blockly/msg/json/en.json | 1 + packages/blockly/msg/json/qqq.json | 1 + packages/blockly/msg/messages.js | 3 ++ .../tests/mocha/shortcut_items_test.js | 51 +++++++++++++++++++ 6 files changed, 121 insertions(+) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 6b533e8bc..e8334f0aa 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -23,6 +23,7 @@ import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import {Msg} from './msg.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; +import * as Tooltip from './tooltip.js'; import {aria} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import {KeyCodes} from './utils/keycodes.js'; @@ -64,6 +65,7 @@ export enum names { PERFORM_ACTION = 'perform_action', DUPLICATE = 'duplicate', CLEANUP = 'cleanup', + SHOW_TOOLTIP = 'show_tooltip', } /** @@ -973,6 +975,32 @@ export function registerCleanup() { ShortcutRegistry.registry.register(cleanupShortcut); } +/** + * Registers keyboard shortcut to display the tooltip for the focused element. + */ +export function registerShowTooltip() { + const ctrlJ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.J, [ + KeyCodes.CTRL_CMD, + ]); + + const showTooltip: KeyboardShortcut = { + name: names.SHOW_TOOLTIP, + preconditionFn: (workspace) => !workspace.isDragging(), + callback: (workspace) => { + const target = getFocusManager().getFocusedNode(); + if (target !== null) { + keyboardNavigationController.setIsActive(true); + Tooltip.display(target, workspace); + } + return true; + }, + keyCodes: [ctrlJ], + allowCollision: true, + displayText: () => Msg['SHORTCUTS_SHOW_TOOLTIP'], + }; + ShortcutRegistry.registry.register(showTooltip); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -1004,6 +1032,7 @@ export function registerKeyboardNavigationShortcuts() { registerPerformAction(); registerDuplicate(); registerCleanup(); + registerShowTooltip(); } /** diff --git a/packages/blockly/core/tooltip.ts b/packages/blockly/core/tooltip.ts index 3c2a37fe6..3a70bc12c 100644 --- a/packages/blockly/core/tooltip.ts +++ b/packages/blockly/core/tooltip.ts @@ -8,6 +8,7 @@ import * as browserEvents from './browser_events.js'; import * as common from './common.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import * as blocklyString from './utils/string.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -363,6 +364,41 @@ export function hide() { } } +/** + * Display the tooltip for a given target. + * + * @internal + * @param target The node upon which the tooltip should be displayed. + * @param workspace The target node's workspace. + */ +export function display(target: IFocusableNode, workspace?: WorkspaceSvg) { + // If the target is not the same element currently displaying a tooltip, hide + // the existing tooltip and set the target as our element. + if (element !== target) { + hide(); + poisonedElement = null; + element = target; + } + + if (!element || !(element as AnyDuringMigration).tooltip) { + // No tooltip here to show. + return; + } else if (blocked) { + // Someone doesn't want us to show tooltips. We are probably handling a + // user gesture, such as a click or drag. + return; + } + + // Set the position to just below the element with horizontal alignment based + // on the target's RTL/LTR orientation. + const targetRect = target.getFocusableElement().getBoundingClientRect(); + const rtl = element.RTL; + lastX = rtl ? targetRect.x + targetRect.width : targetRect.x; + lastY = targetRect.y + targetRect.height; + + show(workspace); +} + /** * Hide any in-progress tooltips and block showing new tooltips until the next * call to unblock(). diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 12fbfe682..349ba11df 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -451,6 +451,7 @@ "SHORTCUTS_PERFORM_ACTION": "Edit or confirm", "SHORTCUTS_DUPLICATE": "Duplicate", "SHORTCUTS_CLEANUP": "Clean up workspace", + "SHORTCUTS_SHOW_TOOLTIP": "Show tooltip", "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Hold %1 and use arrow keys to move freely, then %2 to accept the position", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position", "KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.", diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index d2fcf86cb..d3f6acb59 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -458,6 +458,7 @@ "SHORTCUTS_PERFORM_ACTION": "shortcut display text for the perform action shortcut, which triggers an action on the focused element.", "SHORTCUTS_DUPLICATE": "shortcut display text for the duplicate shortcut, which duplicates the focused block or comment.", "SHORTCUTS_CLEANUP": "shortcut display text for the cleanup shortcut, which organizes blocks on the workspace.", + "SHORTCUTS_SHOW_TOOLTIP": "shortcut display text for the show tooltip shortcut, which displays a short help text for the focused element.", "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks to arbitrary locations with the keyboard.", "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.", "KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index d5cd502d7..1dd563e22 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1786,6 +1786,9 @@ Blockly.Msg.SHORTCUTS_DUPLICATE = 'Duplicate'; /// shortcut display text for the cleanup shortcut, which organizes blocks on the workspace. Blockly.Msg.SHORTCUTS_CLEANUP = 'Clean up workspace'; /** @type {string} */ +/// shortcut display text for the show tooltip shortcut, which displays a short help text for the focused element. +Blockly.Msg.SHORTCUTS_SHOW_TOOLTIP = 'Show tooltip'; +/** @type {string} */ /// Message shown to inform users how to move blocks to arbitrary locations /// with the keyboard. Blockly.Msg.KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT = 'Hold %1 and use arrow keys to move freely, then %2 to accept the position'; diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 7b92b534c..53c284e4d 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -1435,4 +1435,55 @@ suite('Keyboard Shortcut Items', function () { assert.deepEqual(oldBounds, newBounds); }); }); + + suite('Show tooltip (Ctrl/Cmd+J)', function () { + const event = createKeyDownEvent(Blockly.utils.KeyCodes.J, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]); + + test('Displays tooltip on a block using the keyboard shortcut', function () { + const block = this.workspace.newBlock('controls_if'); + Blockly.getFocusManager().focusNode(block); + block.setTooltip('Tooltip Text'); + this.injectionDiv.dispatchEvent(event); + + assert.isTrue(Blockly.Tooltip.isVisible()); + }); + + test('Displays new tooltip on a block using the keyboard shortcut if tooltip for another block is already displayed', function () { + const block1 = this.workspace.newBlock('controls_if'); + const block2 = this.workspace.newBlock('logic_compare'); + + block1.setTooltip('block1'); + block2.setTooltip('block2'); + + // Set focus to block1 and show its tooltip + Blockly.getFocusManager().focusNode(block1); + this.injectionDiv.dispatchEvent(event); + + // We have block1 focused; we should see block1's tooltip + assert.isTrue(Blockly.Tooltip.isVisible()); + assert.isTrue(Blockly.Tooltip.getDiv().innerText === 'block1'); + + // Set focus to block2 and show its tooltip + Blockly.getFocusManager().focusNode(block2); + this.injectionDiv.dispatchEvent(event); + + // Now we have block2 focused; we should see block2's tooltip + assert.isTrue(Blockly.Tooltip.isVisible()); + assert.isTrue(Blockly.Tooltip.getDiv().innerText === 'block2'); + }); + + test('Do not show tooltip if drag in progress', function () { + sinon.stub(this.workspace, 'isDragging').returns(true); + this.injectionDiv.dispatchEvent(event); + + const block = this.workspace.newBlock('controls_if'); + Blockly.getFocusManager().focusNode(block); + block.setTooltip('Tooltip Text'); + this.injectionDiv.dispatchEvent(event); + + assert.isFalse(Blockly.Tooltip.isVisible()); + }); + }); }); From cffbe7c60ecfff2c45e392a75b658f3f0940a14c Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 27 Apr 2026 10:41:10 -0700 Subject: [PATCH 070/200] fix: Remove unneeded scrollbars on contextual menus (#9771) --- packages/blockly/core/css.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index c90581d02..b58c96767 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -380,6 +380,7 @@ input[type=number] { .blocklyContextMenu { border-radius: 4px; max-height: 100%; + box-sizing: content-box; } .blocklyDropdownMenu { From e3672f1581bdf0c43642d01a5dc0686ba7bc814c Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:32:04 -0400 Subject: [PATCH 071/200] feat: `FieldDropdown` ARIA (#9766) * feat: FieldDropdown aria * fix: clean up tests * fix: code review --- packages/blockly/core/block_aria_composer.ts | 2 +- packages/blockly/core/field_dropdown.ts | 190 ++++++++++++- packages/blockly/core/field_number.ts | 14 + packages/blockly/core/field_textinput.ts | 14 + packages/blockly/core/menu.ts | 9 + packages/blockly/core/menuitem.ts | 22 +- packages/blockly/msg/json/en.json | 10 +- packages/blockly/msg/json/qqq.json | 6 +- packages/blockly/msg/messages.js | 18 +- .../tests/mocha/field_dropdown_test.js | 256 ++++++++++++++++++ .../blockly/tests/mocha/field_number_test.js | 14 +- .../tests/mocha/field_textinput_test.js | 14 +- 12 files changed, 538 insertions(+), 31 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index e578154d3..03e53458b 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -5,7 +5,6 @@ */ import type {BlockSvg} from './block_svg.js'; -import {RenderedConnection} from './blockly.js'; import {ConnectionType} from './connection_type.js'; import type {Input} from './inputs/input.js'; import {inputTypes} from './inputs/input_types.js'; @@ -14,6 +13,7 @@ import { isSelectableToolboxItem, } from './interfaces/i_selectable_toolbox_item.js'; import {Msg} from './msg.js'; +import {RenderedConnection} from './rendered_connection.js'; import {Role, setRole, setState, State, Verbosity} from './utils/aria.js'; /** diff --git a/packages/blockly/core/field_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 3be5c94c3..64ecd39a9 100644 --- a/packages/blockly/core/field_dropdown.ts +++ b/packages/blockly/core/field_dropdown.ts @@ -25,6 +25,7 @@ import * as fieldRegistry from './field_registry.js'; import {Menu} from './menu.js'; import {MenuSeparator} from './menu_separator.js'; import {MenuItem} from './menuitem.js'; +import {Msg} from './msg.js'; import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; @@ -98,6 +99,12 @@ export class FieldDropdown extends Field { /** The total vertical padding above and below an image. */ protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2; + /** + * True once the field’s DOM has been created and it is safe to run ARIA + * updates in response to value changes. + */ + isInitialized: boolean = false; + /** * @param menuGenerator A non-empty array of options for a dropdown list, or a * function which generates these options. Also accepts Field.SKIP_SETUP @@ -197,6 +204,8 @@ export class FieldDropdown extends Field { dom.addClass(this.fieldGroup_, 'blocklyField'); dom.addClass(this.fieldGroup_, 'blocklyDropdownField'); } + this.recomputeAriaContext(); + this.isInitialized = true; } /** @@ -295,6 +304,7 @@ export class FieldDropdown extends Field { } this.applyColour(); + aria.setState(this.getFocusableElement(), aria.State.EXPANDED, true); } /** Create the dropdown editor. */ @@ -306,6 +316,11 @@ export class FieldDropdown extends Field { const menu = new Menu(); menu.setRole(aria.Role.LISTBOX); this.menu_ = menu; + aria.setState( + this.getFocusableElement(), + aria.State.CONTROLS, + this.menu_.getId(), + ); const options = this.getOptions(false); this.selectedMenuItem = null; @@ -317,6 +332,7 @@ export class FieldDropdown extends Field { } const [label, value] = option; + const ariaLabel = this.computeOptionAriaLabel(option, i); const content = (() => { if (isImageProperties(label)) { // Convert ImageProperties to an HTMLImageElement. @@ -327,7 +343,7 @@ export class FieldDropdown extends Field { } return label; })(); - const menuItem = new MenuItem(content, value); + const menuItem = new MenuItem(content, value, ariaLabel); menuItem.setRole(aria.Role.OPTION); menuItem.setRightToLeft(block.RTL); menuItem.setCheckable(true); @@ -350,6 +366,7 @@ export class FieldDropdown extends Field { this.menu_ = null; this.selectedMenuItem = null; this.applyColour(); + aria.setState(this.getFocusableElement(), aria.State.EXPANDED, false); } /** @@ -472,6 +489,9 @@ export class FieldDropdown extends Field { this.selectedOption = option; } } + if (this.isInitialized) { + this.recomputeAriaContext(); + } } /** @@ -653,7 +673,7 @@ export class FieldDropdown extends Field { typeof HTMLElement !== 'undefined' && option instanceof HTMLElement ) { - return option.title ?? option.ariaLabel ?? option.innerText; + return option.title || (option.ariaLabel ?? option.innerText); } else if (typeof option === 'string') { return option; } @@ -705,9 +725,16 @@ export class FieldDropdown extends Field { return option; } - const [label, value] = option; + const [label, value, ariaLabel] = option; if (typeof label === 'string') { - return [parsing.replaceMessageReferences(label), value]; + const trimmedLabelOption: MenuOption = [ + parsing.replaceMessageReferences(label), + value, + ]; + if (ariaLabel) { + trimmedLabelOption.push(ariaLabel); + } + return trimmedLabelOption; } hasNonTextContent = true; @@ -716,14 +743,18 @@ export class FieldDropdown extends Field { const imageLabel = isImageProperties(label) ? {...label, alt: parsing.replaceMessageReferences(label.alt)} : label; - return [imageLabel, value]; + const imageOptions: MenuOption = [imageLabel, value]; + if (ariaLabel) { + imageOptions.push(ariaLabel); + } + return imageOptions; }); 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); @@ -762,14 +793,20 @@ 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]) => [ - text.substring(prefixLength, text.length - suffixLength), - value, - ]); + return options.map(([text, value, ariaLabel]) => { + const trimmedText = text.substring( + prefixLength, + text.length - suffixLength, + ); + + return ariaLabel !== undefined + ? [trimmedText, value, ariaLabel] + : [trimmedText, value]; + }); } /** @@ -813,12 +850,140 @@ export class FieldDropdown extends Field { `Invalid option[${i}]: Each FieldDropdown option must have a string label, image description, or HTML element. Found ${option[0]} in: ${option}`, ); + } else if (option[2] && typeof option[2] !== 'string') { + foundError = true; + console.error( + `Invalid option[${i}]: Each FieldDropdown option ARIA label must be a string. + Found ${option[2]} in: ${option}`, + ); } } if (foundError) { throw TypeError('Found invalid FieldDropdown options.'); } } + + /** + * Gets an ARIA-friendly label representation of this field's type. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's type. + * + * @returns An ARIA representation of the field's type or a default if it is + * unspecified. + */ + override getAriaTypeName(): string | null { + return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_DROPDOWN']; + } + + /** + * Gets an ARIA-friendly label representation of this field's value. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's value. + * + * @returns An ARIA representation of the field's text. + */ + override getAriaValue(): string | null { + // Note: This fallback is effectively unreachable since computeOptionAriaLabel + // always returns a non-empty string for non-separator options. It exists as a + // defensive safeguard. + return ( + this.getSelectedAriaLabel() || this.getText() || Msg['FIELD_LABEL_EMPTY'] + ); + } + + /** + * Returns the ARIA label for the currently selected dropdown option. + * + * @returns The computed ARIA label for the selected option, or `null` if no + * option is selected. + */ + private getSelectedAriaLabel(): string | null { + if (!this.selectedOption) { + return null; + } + + const option = this.selectedOption; + const ariaLabel = this.computeOptionAriaLabel( + option, + this.getOptions(false).indexOf(option), + ); + + if (typeof ariaLabel === 'string') { + return ariaLabel; + } + + return null; + } + + /** + * Recomputes the ARIA role and label for this field. + */ + private recomputeAriaContext(): void { + const focusableElement = this.getFocusableElement(); + if (!focusableElement) return; + + if (this.getSourceBlock()?.isInFlyout) { + aria.setState(focusableElement, aria.State.HIDDEN, true); + return; + } + + aria.setState(focusableElement, aria.State.HIDDEN, false); + // The button role is intended to indicate to users that the field has an + // editing mode that can be activated. + aria.setRole(focusableElement, aria.Role.BUTTON); + + const label = this.computeAriaLabel(false); + + aria.setState(focusableElement, aria.State.LABEL, label); + aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox'); + aria.setState(focusableElement, aria.State.EXPANDED, !!this.menu_); + } + + /** + * Computes an ARIA-friendly label for a dropdown option. + * + * The label is derived using a prioritized set of sources. + * + * Returned values are guaranteed to be non-empty strings for all non-separator + * options. Whitespace-only values are ignored when determining a usable label. + * + * @param option The dropdown option for which to compute the ARIA label. + * @param index The index of the option within the dropdown (0-based). + * @returns A string suitable for use as an ARIA label. Returns an empty string + * only if the option is a separator. + */ + private computeOptionAriaLabel(option: MenuOption, index: number): string { + if (option === FieldDropdown.SEPARATOR) return ''; + + const [label, , explicitAriaLabel] = option; + + if (typeof explicitAriaLabel === 'string' && explicitAriaLabel.trim()) { + return explicitAriaLabel; + } + + let text: string | null = null; + + if (isImageProperties(label)) { + text = label.ariaLabel ?? label.alt; + } else if ( + typeof HTMLElement !== 'undefined' && + label instanceof HTMLElement + ) { + // This chain is similar to getText_, but prioritizes ariaLabel over title. + text = label.ariaLabel ?? (label.title || label.innerText); + } else if (typeof label === 'string') { + text = label; + } + + if (text && text.trim()) { + return text; + } + + // If we can't find any text to use for the ARIA label, use the option index. + return Msg['FIELD_LABEL_OPTION_INDEX'].replace('%1', String(index + 1)); + } } /** @@ -850,6 +1015,7 @@ export interface ImageProperties { alt: string; width: number; height: number; + ariaLabel?: string; } /** @@ -860,7 +1026,7 @@ export interface ImageProperties { * the language-neutral value. */ export type MenuOption = - | [string | ImageProperties | HTMLElement, string] + | [string | ImageProperties | HTMLElement, string, string?] | 'separator'; /** diff --git a/packages/blockly/core/field_number.ts b/packages/blockly/core/field_number.ts index 7e3659175..6e7088bce 100644 --- a/packages/blockly/core/field_number.ts +++ b/packages/blockly/core/field_number.ts @@ -18,6 +18,7 @@ import { FieldInputValidator, } from './field_input.js'; import * as fieldRegistry from './field_registry.js'; +import {Msg} from './msg.js'; import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; @@ -341,6 +342,19 @@ export class FieldNumber extends FieldInput { options, ); } + + /** + * Gets an ARIA-friendly label representation of this field's type. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's type. + * + * @returns An ARIA representation of the field's type or a default if it is + * unspecified. + */ + override getAriaTypeName(): string | null { + return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_NUMBER']; + } } fieldRegistry.register('field_number', FieldNumber); diff --git a/packages/blockly/core/field_textinput.ts b/packages/blockly/core/field_textinput.ts index 2b896ad47..bd6f8cff3 100644 --- a/packages/blockly/core/field_textinput.ts +++ b/packages/blockly/core/field_textinput.ts @@ -21,6 +21,7 @@ import { FieldInputValidator, } from './field_input.js'; import * as fieldRegistry from './field_registry.js'; +import {Msg} from './msg.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -89,6 +90,19 @@ export class FieldTextInput extends FieldInput { // override the static fromJson method. return new this(text, undefined, options); } + + /** + * Gets an ARIA-friendly label representation of this field's type. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's type. + * + * @returns An ARIA representation of the field's type or a default if it is + * unspecified. + */ + override getAriaTypeName(): string | null { + return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_TEXT_INPUT']; + } } fieldRegistry.register('field_input', FieldTextInput); diff --git a/packages/blockly/core/menu.ts b/packages/blockly/core/menu.ts index a064489ba..8d7585681 100644 --- a/packages/blockly/core/menu.ts +++ b/packages/blockly/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,6 +63,9 @@ export class Menu { /** ARIA name for this menu. */ private roleName: aria.Role | null = null; + /** The menu's ID. */ + private id: string = idGenerator.getNextUniqueId(); + /** Constructs a new Menu instance. */ constructor() {} @@ -86,6 +90,7 @@ export class Menu { const element = document.createElement('div'); element.className = 'blocklyMenu'; element.tabIndex = 0; + element.id = this.getId(); if (this.roleName) { aria.setRole(element, this.roleName); } @@ -483,4 +488,8 @@ export class Menu { private getMenuItems(): MenuItem[] { return this.menuItems.filter((item) => item instanceof MenuItem); } + + getId(): string { + return this.id; + } } diff --git a/packages/blockly/core/menuitem.ts b/packages/blockly/core/menuitem.ts index 454e35744..9500e322c 100644 --- a/packages/blockly/core/menuitem.ts +++ b/packages/blockly/core/menuitem.ts @@ -43,6 +43,9 @@ export class MenuItem { private actionHandler: ((obj: this, menuSelectEvent: Event) => void) | null = null; + /** The unique ID for this menu item. */ + private id: string = idGenerator.getNextUniqueId(); + /** * @param content Text caption to display as the content of the item, or a * HTML element to display. @@ -51,6 +54,7 @@ export class MenuItem { constructor( private readonly content: string | HTMLElement, private readonly opt_value?: string, + private readonly ariaLabel?: string, ) {} /** @@ -60,7 +64,7 @@ export class MenuItem { */ createDom(): Element { const element = document.createElement('div'); - element.id = idGenerator.getNextUniqueId(); + element.id = this.getId(); this.element = element; // Set class and style @@ -72,6 +76,10 @@ export class MenuItem { (this.rightToLeft ? 'blocklyMenuItemRtl ' : ''); const content = document.createElement('div'); + aria.setState(element, aria.State.LABEL, this.getAriaLabel()); + // The presentation role is used to prevent screen readers from also reading the + // content or its descendants. + aria.setRole(content, aria.Role.PRESENTATION); content.className = 'blocklyMenuItemContent'; let contentDom: Node = this.content as HTMLElement; @@ -100,6 +108,15 @@ export class MenuItem { return element; } + /** + * Gets the ARIA label for this menu item. + */ + getAriaLabel(): string { + // This fallback should only be hit by Context Menu items as all + // FieldDropdown options should have an ARIA label. + return this.ariaLabel || String(this.content); + } + /** Dispose of this menu item. */ dispose() { this.element = null; @@ -122,7 +139,7 @@ export class MenuItem { * @internal */ getId(): string { - return this.element!.id; + return this.id; } /** @@ -276,6 +293,7 @@ export class MenuItem { } const checkbox = document.createElement('div'); + aria.setState(checkbox, aria.State.HIDDEN, true); checkbox.className = 'blocklyMenuItemCheckbox '; this.getElement() ?.querySelector('.blocklyMenuItemContent') diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 349ba11df..e71e84af6 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-21 17:30:08.288719", + "lastupdated": "2026-04-24 15:03:55.288228", "locale": "en", "messagedocumentation" : "qqq" }, @@ -484,6 +484,10 @@ "ANNOUNCE_MOVE_TO": "moving %1 %2 to %3 %4", "ANNOUNCE_MOVE_CANCELED": "Canceled movement", "FIELD_LABEL_EMPTY": "empty", - "ARIA_TYPE_FIELD_INPUT": "input field", - "FIELD_LABEL_EDIT_PREFIX": "Edit %1" + "ARIA_TYPE_FIELD_INPUT": "input", + "ARIA_TYPE_FIELD_TEXT_INPUT": "text", + "ARIA_TYPE_FIELD_NUMBER": "number", + "ARIA_TYPE_FIELD_DROPDOWN": "dropdown", + "FIELD_LABEL_EDIT_PREFIX": "Edit %1", + "FIELD_LABEL_OPTION_INDEX": "Option %1" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index d3f6acb59..ee2d9be3a 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -492,5 +492,9 @@ "ANNOUNCE_MOVE_CANCELED": "ARIA live region message announcing a block movement has been canceled.", "FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content.", "ARIA_TYPE_FIELD_INPUT": "ARIA type name for an input field, used by screen readers to identify the type of field.", - "FIELD_LABEL_EDIT_PREFIX": "Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. \n\nParameters:\n* %1 - the label of the field's value \n\nExamples:\n* 'Edit 5'\n* 'Edit item'" + "ARIA_TYPE_FIELD_TEXT_INPUT": "ARIA type name for a text input field, used by screen readers to identify the type of field.", + "ARIA_TYPE_FIELD_NUMBER": "ARIA type name for a number field, used by screen readers to identify the type of field.", + "ARIA_TYPE_FIELD_DROPDOWN": "ARIA type name for a dropdown field, used by screen readers to identify the type of field.", + "FIELD_LABEL_EDIT_PREFIX": "Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. \n\nParameters:\n* %1 - the label of the field's value \n\nExamples:\n* 'Edit 5'\n* 'Edit item'", + "FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'" } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 1dd563e22..1df586d02 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1929,9 +1929,23 @@ Blockly.Msg.ANNOUNCE_MOVE_CANCELED = 'Canceled movement'; Blockly.Msg.FIELD_LABEL_EMPTY = 'empty'; /** @type {string} */ /// ARIA type name for an input field, used by screen readers to identify the type of field. -Blockly.Msg.ARIA_TYPE_FIELD_INPUT = 'input field'; +Blockly.Msg.ARIA_TYPE_FIELD_INPUT = 'input'; +/** @type {string} */ +/// ARIA type name for a text input field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_TEXT_INPUT = 'text'; +/** @type {string} */ +/// ARIA type name for a number field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_NUMBER = 'number'; +/** @type {string} */ +/// ARIA type name for a dropdown field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_DROPDOWN = 'dropdown'; /** @type {string} */ /// Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. /// \n\nParameters:\n* %1 - the label of the field's value /// \n\nExamples:\n* "Edit 5"\n* "Edit item" -Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1'; \ No newline at end of file +Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1'; +/** @type {string} */ +/// Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. +/// \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 +/// \n\nExamples:\n* "Option 1"\n* "Option 2" +Blockly.Msg.FIELD_LABEL_OPTION_INDEX = 'Option %1'; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/field_dropdown_test.js b/packages/blockly/tests/mocha/field_dropdown_test.js index a1731e812..c8d02c480 100644 --- a/packages/blockly/tests/mocha/field_dropdown_test.js +++ b/packages/blockly/tests/mocha/field_dropdown_test.js @@ -325,4 +325,260 @@ suite('Dropdown Fields', function () { this.assertValue('C', field); }); }); + + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + }); + suite('Simple Dropdown', function () { + setup(function () { + this.block = this.workspace.newBlock('logic_boolean'); + this.field = this.block.getField('BOOL'); + this.block.initSvg(); + this.block.render(); + + this.focusableElement = this.field.getFocusableElement(); + }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, 'dropdown:'); + }); + test('Focusable element has role of button', function () { + const role = this.focusableElement.getAttribute('role'); + assert.equal(role, 'button'); + }); + test('Hidden when in a flyout', function () { + this.block.isInFlyout = true; + // Force recompute of ARIA label. + this.field.setValue(this.field.getValue()); + const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); + assert.equal(ariaHidden, 'true'); + }); + test('Does not have aria-expanded when dropdown is closed', function () { + const ariaExpanded = + this.focusableElement.getAttribute('aria-expanded'); + assert.equal(ariaExpanded, 'false'); + }); + test('Has aria-expanded when dropdown is open', function () { + this.field.showEditor_(); + const ariaExpanded = + this.focusableElement.getAttribute('aria-expanded'); + assert.equal(ariaExpanded, 'true'); + this.workspace.hideChaff(); + }); + test('Has aria-haspopup of listbox', function () { + const ariaHasPopup = + this.focusableElement.getAttribute('aria-haspopup'); + assert.equal(ariaHasPopup, 'listbox'); + }); + test('Has aria-controls that matches the ID of the dropdown menu', function () { + this.field.showEditor_(); + const ariaControls = + this.focusableElement.getAttribute('aria-controls'); + const menuId = this.field.menu_.id; + assert.equal(ariaControls, menuId); + this.workspace.hideChaff(); + }); + test('Has placeholder ARIA label by default', function () { + const label = this.focusableElement.getAttribute('aria-label'); + assert.include(label, 'true'); + }); + test('setValue updates ARIA label', function () { + const initialLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(initialLabel, 'true'); + this.field.setValue('FALSE'); + const updatedLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(updatedLabel, 'false'); + }); + }); + suite('Dropdown with Option ARIA labels', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'math_op', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'OP', + 'options': [ + ['%{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'], + ], + }, + ], + }, + ]); + const block = this.workspace.newBlock('math_op'); + block.initSvg(); + block.render(); + this.field = block.getField('OP'); + }); + test('Option ARIA labels are included in field ARIA label', function () { + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.include(label, 'Plus'); + }); + test('Option ARIA labels are included in field ARIA label when value is changed', function () { + this.field.setValue('DIVIDE'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.include(label, 'Divided by'); + }); + }); + suite('Dropdown with image options', function () { + setup(function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'image_dropdown_test', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'IMG', + 'options': [ + [ + { + 'src': + 'https://blockly-demo.appspot.com/static/tests/media/a.png', + 'width': 32, + 'height': 32, + 'alt': 'A', + }, + 'A', + ], + [ + { + 'src': + 'https://blockly-demo.appspot.com/static/tests/media/b.png', + 'width': 32, + 'height': 32, + 'alt': 'B', + 'ariaLabel': 'Letter B', + }, + 'B', + ], + ], + }, + ], + }, + ]); + const block = this.workspace.newBlock('image_dropdown_test'); + block.initSvg(); + block.render(); + this.field = block.getField('IMG'); + }); + test('Image alt text is included in ARIA label', function () { + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(label, 'A'); + }); + test('Image ARIA label is prioritized over alt text', function () { + this.field.dropdownCreate(); + this.field.setValue('B'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(label, 'Letter B'); + }); + }); + suite('Dropdown with HTMLElement options', function () { + setup(function () { + function makeElementOption({ariaLabel, title, innerText}) { + const element = document.createElement('div'); + if (ariaLabel) element.ariaLabel = ariaLabel; + if (title) element.title = title; + if (innerText) element.innerText = innerText; + return element; + } + const options = [ + [ + makeElementOption({ + ariaLabel: 'Ignored', + title: 'Ignored', + innerText: 'Ignored', + }), + 'A', + 'Explicit A label', + ], + [ + makeElementOption({ + ariaLabel: 'Element ARIA', + title: 'Ignored', + innerText: 'Ignored', + }), + 'B', + ], + [ + makeElementOption({ + title: 'Title text', + innerText: 'Ignored', + }), + 'C', + ], + [makeElementOption({innerText: 'Inner text'}), 'D'], + [makeElementOption({}), 'E'], + ]; + + Blockly.Blocks['aria_dropdown_test'] = { + init: function () { + this.appendDummyInput().appendField( + new Blockly.FieldDropdown(options), + 'OP', + ); + + this.setOutput(true, null); + this.setColour(230); + }, + }; + const block = this.workspace.newBlock('aria_dropdown_test'); + block.initSvg(); + block.render(); + this.field = block.getField('OP'); + }); + test('Explicit ARIA label overrides all other label sources', function () { + this.field.setValue('A'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(label, 'Explicit A label'); + }); + test('HTMLElement ariaLabel prioritized over other properties', function () { + this.field.setValue('B'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(label, 'Element ARIA'); + }); + test('HTMLElement title is used when ariaLabel is missing', function () { + this.field.setValue('C'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(label, 'Title text'); + }); + test('HTMLElement innerText is used as final fallback', function () { + this.field.setValue('D'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(label, 'Inner text'); + }); + test('Empty label falls back to option index', function () { + this.field.setValue('E'); + const label = this.field + .getFocusableElement() + .getAttribute('aria-label'); + assert.equal(label, 'Option 5'); + }); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index 692633bfa..918bf3917 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -507,19 +507,23 @@ suite('Number Fields', function () { this.workspace = Blockly.inject('blocklyDiv', { renderer: 'geras', }); - const block = this.workspace.newBlock('math_number'); - this.field = block.getField('NUM'); - block.initSvg(); - block.render(); + this.block = this.workspace.newBlock('math_number'); + this.field = this.block.getField('NUM'); + this.block.initSvg(); + this.block.render(); this.focusableElement = this.field.getClickTarget_(); }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, 'number:'); + }); test('Focusable element has role of button', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'button'); }); test('Hidden when in a flyout', function () { - this.field.getSourceBlock().isInFlyout = true; + this.block.isInFlyout = true; // Force recompute of ARIA label. this.field.setValue(this.field.getValue()); const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index 0ab0c7452..ab3fca359 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -598,19 +598,23 @@ suite('Text Input Fields', function () { this.workspace = Blockly.inject('blocklyDiv', { renderer: 'geras', }); - const block = this.workspace.newBlock('text'); - this.field = block.getField('TEXT'); - block.initSvg(); - block.render(); + this.block = this.workspace.newBlock('text'); + this.field = this.block.getField('TEXT'); + this.block.initSvg(); + this.block.render(); this.focusableElement = this.field.getClickTarget_(); }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, 'text:'); + }); test('Focusable element has role of button', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'button'); }); test('Hidden when in a flyout', function () { - this.field.getSourceBlock().isInFlyout = true; + this.block.isInFlyout = true; // Force recompute of ARIA label. this.field.setValue(this.field.getValue()); const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); From 4aea3dd6393dcec476db5f2435b5bf4367c13754 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 29 Apr 2026 09:16:27 -0700 Subject: [PATCH 072/200] fix: Fix bug that caused focus rings to be inverted in RTL Zelos (#9767) --- packages/blockly/core/renderers/zelos/path_object.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/blockly/core/renderers/zelos/path_object.ts b/packages/blockly/core/renderers/zelos/path_object.ts index 36eed1dc9..36dadfa1c 100644 --- a/packages/blockly/core/renderers/zelos/path_object.ts +++ b/packages/blockly/core/renderers/zelos/path_object.ts @@ -97,6 +97,7 @@ export class PathObject extends BasePathObject { 'stroke': this.svgPath.getAttribute('stroke') || '', 'fill': this.svgPath.getAttribute('fill') || '', 'd': this.svgPath.getAttribute('d') || '', + 'transform': this.svgPath.getAttribute('transform') || '', 'role': Role.PRESENTATION, }, this.svgRoot, From f8ba2016af98ec83693ee0df39abe99c120d0f77 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 29 Apr 2026 09:17:32 -0700 Subject: [PATCH 073/200] fix: Fix Escape in toolboxes and flyouts (#9770) * fix: Fix Escape in toolboxes and flyouts * chore: Run formatter --- packages/blockly/core/flyout_base.ts | 11 ++++ packages/blockly/core/toolbox/toolbox.ts | 4 ++ .../tests/mocha/shortcut_items_test.js | 63 +++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/packages/blockly/core/flyout_base.ts b/packages/blockly/core/flyout_base.ts index 3e0dbbee1..eab100253 100644 --- a/packages/blockly/core/flyout_base.ts +++ b/packages/blockly/core/flyout_base.ts @@ -289,6 +289,17 @@ export abstract class Flyout .getThemeManager() .subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity'); + this.svgGroup_.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') { + getFocusManager().focusTree(this.targetWorkspace); + if (!this.targetWorkspace.isMutator) { + this.autoHide(false); + } + e.preventDefault(); + e.stopPropagation(); + } + }); + return this.svgGroup_; } diff --git a/packages/blockly/core/toolbox/toolbox.ts b/packages/blockly/core/toolbox/toolbox.ts index c77f32a86..22d5056be 100644 --- a/packages/blockly/core/toolbox/toolbox.ts +++ b/packages/blockly/core/toolbox/toolbox.ts @@ -311,6 +311,10 @@ export class Toolbox case 'ArrowRight': handled = this.toggleSelectedItem(true); break; + case 'Escape': + getFocusManager().focusTree(this.getWorkspace()); + handled = true; + break; } if (handled) { diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 53c284e4d..00a0b20b9 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -89,27 +89,90 @@ suite('Keyboard Shortcut Items', function () { 'hideChaff', ); }); + test('Simple', function () { this.injectionDiv.dispatchEvent(this.event); sinon.assert.calledOnce(this.hideChaffSpy); }); + runReadOnlyTest(createKeyDownEvent(Blockly.utils.KeyCodes.ESC)); + test('Not called when focus is on an HTML input', function () { const event = createKeyDownEvent(Blockly.utils.KeyCodes.ESC); const input = document.createElement('textarea'); input.dispatchEvent(event); sinon.assert.notCalled(this.hideChaffSpy); }); + test('Not called on hidden workspaces', function () { this.workspace.visible = false; this.injectionDiv.dispatchEvent(this.event); sinon.assert.notCalled(this.hideChaffSpy); }); + test('Called when connection is focused', function () { setSelectedConnection(this.workspace); this.injectionDiv.dispatchEvent(this.event); sinon.assert.calledOnce(this.hideChaffSpy); }); + + test('In the toolbox focuses the workspace', function () { + Blockly.getFocusManager().focusNode( + this.workspace.getToolbox().getToolboxItems()[0], + ); + assert.equal( + Blockly.getFocusManager().getFocusedTree(), + this.workspace.getToolbox(), + ); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.ESC, + key: 'Escape', + }); + this.workspace.getToolbox().contentsDiv_.dispatchEvent(event); + assert.equal(Blockly.getFocusManager().getFocusedTree(), this.workspace); + }); + + test('In the flyout focues the workspace', function () { + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.T), + ); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.RIGHT), + ); + assert.equal( + Blockly.getFocusManager().getFocusedTree(), + this.workspace.getFlyout().getWorkspace(), + ); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.ESC, + key: 'Escape', + }); + this.workspace.getFlyout().svgGroup_.dispatchEvent(event); + assert.equal(Blockly.getFocusManager().getFocusedTree(), this.workspace); + }); + + test('In a mutator flyout focuses the mutator workspace', async function () { + const block = this.workspace.newBlock('controls_if'); + block.initSvg(); + block.render(); + + const mutatorIcon = block.getIcon(Blockly.icons.MutatorIcon.TYPE); + await mutatorIcon.setBubbleVisible(true); + + const bubble = mutatorIcon.getBubble(); + Blockly.getFocusManager().focusTree( + bubble.getWorkspace().getFlyout().getWorkspace(), + ); + const event = new KeyboardEvent('keydown', { + keyCode: Blockly.utils.KeyCodes.ESC, + key: 'Escape', + }); + bubble.getWorkspace().getFlyout().svgGroup_.dispatchEvent(event); + assert.equal( + Blockly.getFocusManager().getFocusedTree(), + bubble.getWorkspace(), + ); + }); }); suite('Delete', function () { From 86fa3318676eb824cb17d7430eb68909909ebbd3 Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:18:03 -0400 Subject: [PATCH 074/200] feat: ARIA for other field classes (#9772) * feat: ARIA for other field classes * fix: code review --- packages/blockly/blocks/variables.ts | 4 +- packages/blockly/blocks/variables_dynamic.ts | 4 +- packages/blockly/core/field.ts | 2 +- packages/blockly/core/field_checkbox.ts | 62 ++++++++++++++ packages/blockly/core/field_dropdown.ts | 6 +- packages/blockly/core/field_image.ts | 58 +++++++++++++ packages/blockly/core/field_input.ts | 4 +- packages/blockly/core/field_label.ts | 2 + packages/blockly/core/field_number.ts | 3 - packages/blockly/core/field_variable.ts | 14 +++- packages/blockly/msg/json/en.json | 11 ++- packages/blockly/msg/json/qqq.json | 7 +- packages/blockly/msg/messages.js | 21 ++++- .../tests/mocha/field_checkbox_test.js | 46 ++++++++++ .../tests/mocha/field_dropdown_test.js | 18 ++-- .../blockly/tests/mocha/field_image_test.js | 51 ++++++++++++ .../blockly/tests/mocha/field_label_test.js | 16 ++++ .../blockly/tests/mocha/field_number_test.js | 4 + .../tests/mocha/field_textinput_test.js | 4 + .../tests/mocha/field_variable_test.js | 83 ++++++++++++++++++- 20 files changed, 391 insertions(+), 29 deletions(-) diff --git a/packages/blockly/blocks/variables.ts b/packages/blockly/blocks/variables.ts index 4f1f640fa..f05ec1d8a 100644 --- a/packages/blockly/blocks/variables.ts +++ b/packages/blockly/blocks/variables.ts @@ -117,12 +117,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = { this.type === 'variables_get' || this.type === 'variables_get_reporter' ) { + const name = this.getField('VAR')!.getText(); const renameOption = { - text: Msg['RENAME_VARIABLE'], + text: Msg['RENAME_VARIABLE'].replace('%1', name), enabled: true, callback: renameOptionCallbackFactory(this), }; - const name = this.getField('VAR')!.getText(); const deleteOption = { text: Msg['DELETE_VARIABLE'].replace('%1', name), enabled: true, diff --git a/packages/blockly/blocks/variables_dynamic.ts b/packages/blockly/blocks/variables_dynamic.ts index 8afd24cf2..7dfc877f5 100644 --- a/packages/blockly/blocks/variables_dynamic.ts +++ b/packages/blockly/blocks/variables_dynamic.ts @@ -117,12 +117,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = { this.type === 'variables_get_dynamic' || this.type === 'variables_get_reporter_dynamic' ) { + const name = this.getField('VAR')!.getText(); const renameOption = { - text: Msg['RENAME_VARIABLE'], + text: Msg['RENAME_VARIABLE'].replace('%1', name), enabled: true, callback: renameOptionCallbackFactory(this), }; - const name = this.getField('VAR')!.getText(); const deleteOption = { text: Msg['DELETE_VARIABLE'].replace('%1', name), enabled: true, diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index e8b83c574..1de0d7899 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -373,7 +373,7 @@ export abstract class Field * @param includeTypeInfo Whether to include the field's type information in * the returned label, if available. */ - computeAriaLabel(includeTypeInfo: boolean = false): string { + computeAriaLabel(includeTypeInfo: boolean = true): string { const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null; let ariaValue = this.getAriaValue(); if (ariaValue === null || ariaValue === '') { diff --git a/packages/blockly/core/field_checkbox.ts b/packages/blockly/core/field_checkbox.ts index 55ed42cbf..f09c579be 100644 --- a/packages/blockly/core/field_checkbox.ts +++ b/packages/blockly/core/field_checkbox.ts @@ -16,6 +16,8 @@ import './events/events_block_change.js'; import {Field, FieldConfig, FieldValidator} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import {Msg} from './msg.js'; +import {aria} from './utils.js'; import * as dom from './utils/dom.js'; type BoolString = 'TRUE' | 'FALSE'; @@ -111,6 +113,7 @@ export class FieldCheckbox extends Field { const textElement = this.getTextElement(); dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField'); textElement.style.display = this.value_ ? 'block' : 'none'; + this.recomputeAriaContext(); } override render_() { @@ -170,6 +173,7 @@ export class FieldCheckbox extends Field { if (this.textElement_) { this.textElement_.style.display = this.value_ ? 'block' : 'none'; } + this.recomputeAriaContext(); } /** @@ -213,6 +217,39 @@ export class FieldCheckbox extends Field { return !!value; } + /** + * Gets an ARIA-friendly label representation of this field's type. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's type. + * + * @returns An ARIA representation of the field's type or a default if it is + * unspecified. + */ + override getAriaTypeName(): string { + return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_CHECKBOX']; + } + + /** + * Gets an ARIA-friendly label representation of this field's value. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's value. + * + * The FieldCheckbox implementation is not used for the actual ARIA label of + * the field, since the checked state is already included in the ARIA checked + * state, but it is used for the ARIA label of its source block. + * + * @returns An ARIA representation of the field's text. + */ + override getAriaValue(): string | null { + // return null; + const checked = this.convertValueToBool(this.value_); + return checked + ? Msg['FIELD_LABEL_CHECKBOX_CHECKED'] + : Msg['FIELD_LABEL_CHECKBOX_UNCHECKED']; + } + /** * Construct a FieldCheckbox from a JSON arg object. * @@ -228,6 +265,31 @@ export class FieldCheckbox extends Field { // 'override' the static fromJson method. return new this(options.checked, undefined, options); } + + /** + * Recomputes the ARIA role and label for this field. + */ + protected recomputeAriaContext(): void { + const focusableElement = this.getClickTarget_(); + if (!focusableElement) return; + + if (this.getSourceBlock()?.isInFlyout) { + aria.setState(focusableElement, aria.State.HIDDEN, true); + return; + } + + aria.setState(focusableElement, aria.State.HIDDEN, false); + aria.setRole(focusableElement, aria.Role.CHECKBOX); + const checked = this.convertValueToBool(this.value_); + aria.setState(focusableElement, aria.State.CHECKED, checked); + + // Checkbox fields do not use this.computeAriaLabel(), because the + // included 'checked' or 'not checked' state in the ARIA label would + // be redundant with the ARIA checked state. + const label = this.getAriaTypeName(); + + aria.setState(focusableElement, aria.State.LABEL, label); + } } fieldRegistry.register('field_checkbox', FieldCheckbox); diff --git a/packages/blockly/core/field_dropdown.ts b/packages/blockly/core/field_dropdown.ts index 64ecd39a9..136c36f69 100644 --- a/packages/blockly/core/field_dropdown.ts +++ b/packages/blockly/core/field_dropdown.ts @@ -884,7 +884,7 @@ export class FieldDropdown extends Field { * * @returns An ARIA representation of the field's text. */ - override getAriaValue(): string | null { + override getAriaValue(): string { // Note: This fallback is effectively unreachable since computeOptionAriaLabel // always returns a non-empty string for non-separator options. It exists as a // defensive safeguard. @@ -920,7 +920,7 @@ export class FieldDropdown extends Field { /** * Recomputes the ARIA role and label for this field. */ - private recomputeAriaContext(): void { + protected recomputeAriaContext(): void { const focusableElement = this.getFocusableElement(); if (!focusableElement) return; @@ -934,7 +934,7 @@ export class FieldDropdown extends Field { // editing mode that can be activated. aria.setRole(focusableElement, aria.Role.BUTTON); - const label = this.computeAriaLabel(false); + const label = this.computeAriaLabel(true); aria.setState(focusableElement, aria.State.LABEL, label); aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox'); diff --git a/packages/blockly/core/field_image.ts b/packages/blockly/core/field_image.ts index 01133c203..4dce9bb00 100644 --- a/packages/blockly/core/field_image.ts +++ b/packages/blockly/core/field_image.ts @@ -13,6 +13,8 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import {Msg} from './msg.js'; +import {aria} from './utils.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; @@ -157,6 +159,8 @@ export class FieldImage extends Field { if (this.clickHandler) { this.imageElement.style.cursor = 'pointer'; } + + this.recomputeAriaContext(); } override updateSize_() {} @@ -186,6 +190,7 @@ export class FieldImage extends Field { if (this.imageElement) { this.imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', this.value_); } + this.recomputeAriaContext(); } /** @@ -283,6 +288,59 @@ export class FieldImage extends Field { options, ); } + + /** + * Gets an ARIA-friendly label representation of this field's type. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's type. + * + * @returns An ARIA representation of the field's type or a default if it is + * unspecified. + */ + override getAriaTypeName(): string | null { + return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_IMAGE']; + } + + /** + * Gets an ARIA-friendly label representation of this field's value. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's value. + * + * @returns An ARIA representation of the field's text, or null if no text is + * currently defined or known for the field. + */ + override getAriaValue(): string | null { + return this.altText || null; + } + + /** + * Recomputes the ARIA role and label for this field. + */ + protected recomputeAriaContext(): void { + const focusableElement = this.getClickTarget_(); + if (!focusableElement) return; + + const isInFlyout = this.getSourceBlock()?.isInFlyout; + if (isInFlyout) { + aria.setState(focusableElement, aria.State.HIDDEN, true); + return; + } + + aria.setState(focusableElement, aria.State.HIDDEN, false); + // The button role is intended to indicate to users that the field has an + // editing mode that can be activated. The presentation role is used to + // prevent screen readers from reading the content or its descendants. + // Only clickable image fields are navigable. + aria.setRole( + focusableElement, + this.isClickable() ? aria.Role.BUTTON : aria.Role.PRESENTATION, + ); + + const label = this.computeAriaLabel(true); + aria.setState(focusableElement, aria.State.LABEL, label); + } } fieldRegistry.register('field_image', FieldImage); diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index 5f024bdab..892f8abf0 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -839,7 +839,7 @@ export abstract class FieldInput extends Field< /** * Recomputes the ARIA role and label for this field. */ - private recomputeAriaContext(): void { + protected recomputeAriaContext(): void { const focusableElement = this.getClickTarget_(); if (!focusableElement) return; @@ -853,7 +853,7 @@ export abstract class FieldInput extends Field< // editing mode that can be activated. aria.setRole(focusableElement, aria.Role.BUTTON); - let label = this.computeAriaLabel(false); + let label = this.computeAriaLabel(true); if (this.isCurrentlyEditable?.()) { label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label); diff --git a/packages/blockly/core/field_label.ts b/packages/blockly/core/field_label.ts index 236154cc7..16745d3f9 100644 --- a/packages/blockly/core/field_label.ts +++ b/packages/blockly/core/field_label.ts @@ -14,6 +14,7 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import {aria} from './utils.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -76,6 +77,7 @@ export class FieldLabel extends Field { } if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyLabelField'); + aria.setState(this.fieldGroup_, aria.State.HIDDEN, true); } } diff --git a/packages/blockly/core/field_number.ts b/packages/blockly/core/field_number.ts index 6e7088bce..34b9bfcb0 100644 --- a/packages/blockly/core/field_number.ts +++ b/packages/blockly/core/field_number.ts @@ -19,7 +19,6 @@ import { } from './field_input.js'; import * as fieldRegistry from './field_registry.js'; import {Msg} from './msg.js'; -import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; /** @@ -300,11 +299,9 @@ export class FieldNumber extends FieldInput { // 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; } diff --git a/packages/blockly/core/field_variable.ts b/packages/blockly/core/field_variable.ts index aa4fdfe31..dfbb218ac 100644 --- a/packages/blockly/core/field_variable.ts +++ b/packages/blockly/core/field_variable.ts @@ -605,7 +605,7 @@ export class FieldVariable extends FieldDropdown { ]; } options.push([ - Msg['RENAME_VARIABLE'], + Msg['RENAME_VARIABLE'].replace('%1', name), internalConstants.RENAME_VARIABLE_ID, ]); if (Msg['DELETE_VARIABLE']) { @@ -617,6 +617,18 @@ export class FieldVariable extends FieldDropdown { return options; } + /** + * Gets an ARIA-friendly label representation of this field's value. + * + * Implementations are responsible for, and encouraged to, return a localized + * version of the ARIA representation of the field's value. + * + * @returns An ARIA representation of the field's text. + */ + override getAriaValue(): string { + // Example: 'Variable "i"' + return Msg['FIELD_LABEL_VARIABLE'].replace('%1', super.getAriaValue()); + } } fieldRegistry.register('field_variable', FieldVariable); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index e71e84af6..caf6c4986 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-24 15:03:55.288228", + "lastupdated": "2026-04-29 08:57:47.670420", "locale": "en", "messagedocumentation" : "qqq" }, @@ -29,7 +29,7 @@ "UNDO": "Undo", "REDO": "Redo", "CHANGE_VALUE_TITLE": "Change value:", - "RENAME_VARIABLE": "Rename variable...", + "RENAME_VARIABLE": "Rename the '%1' variable", "RENAME_VARIABLE_TITLE": "Rename all '%1' variables to:", "NEW_VARIABLE": "Create variable...", "NEW_STRING_VARIABLE": "Create string variable...", @@ -488,6 +488,11 @@ "ARIA_TYPE_FIELD_TEXT_INPUT": "text", "ARIA_TYPE_FIELD_NUMBER": "number", "ARIA_TYPE_FIELD_DROPDOWN": "dropdown", + "ARIA_TYPE_FIELD_IMAGE": "image", + "ARIA_TYPE_FIELD_CHECKBOX": "checkbox", "FIELD_LABEL_EDIT_PREFIX": "Edit %1", - "FIELD_LABEL_OPTION_INDEX": "Option %1" + "FIELD_LABEL_OPTION_INDEX": "Option %1", + "FIELD_LABEL_CHECKBOX_CHECKED": "Checked", + "FIELD_LABEL_CHECKBOX_UNCHECKED": "Not checked", + "FIELD_LABEL_VARIABLE": "Variable '%1'" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index ee2d9be3a..d13ce9e9b 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -495,6 +495,11 @@ "ARIA_TYPE_FIELD_TEXT_INPUT": "ARIA type name for a text input field, used by screen readers to identify the type of field.", "ARIA_TYPE_FIELD_NUMBER": "ARIA type name for a number field, used by screen readers to identify the type of field.", "ARIA_TYPE_FIELD_DROPDOWN": "ARIA type name for a dropdown field, used by screen readers to identify the type of field.", + "ARIA_TYPE_FIELD_IMAGE": "ARIA type name of an image field, used by screen readers to identify the type of field.", + "ARIA_TYPE_FIELD_CHECKBOX": "ARIA type name of an checkbox field, used by screen readers to identify the type of field.", "FIELD_LABEL_EDIT_PREFIX": "Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. \n\nParameters:\n* %1 - the label of the field's value \n\nExamples:\n* 'Edit 5'\n* 'Edit item'", - "FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'" + "FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'", + "FIELD_LABEL_CHECKBOX_CHECKED": "Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field.", + "FIELD_LABEL_CHECKBOX_UNCHECKED": "Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field.", + "FIELD_LABEL_VARIABLE": "Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. \n\nParameters:\n* %1 - the name of the variable represented by the option \n\nExamples:\n* 'Variable 'item''\n* 'Variable 'x''" } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 1df586d02..fb353964a 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -139,7 +139,7 @@ Blockly.Msg.REDO = 'Redo'; Blockly.Msg.CHANGE_VALUE_TITLE = 'Change value:'; /** @type {string} */ /// 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/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu]. -Blockly.Msg.RENAME_VARIABLE = 'Rename variable...'; +Blockly.Msg.RENAME_VARIABLE = 'Rename the "%1" variable'; /** @type {string} */ /// prompt - Prompts the user to enter the new name for the selected variable. See [https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu https://github.com/RaspberryPiFoundation/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:'; @@ -1940,6 +1940,12 @@ Blockly.Msg.ARIA_TYPE_FIELD_NUMBER = 'number'; /// ARIA type name for a dropdown field, used by screen readers to identify the type of field. Blockly.Msg.ARIA_TYPE_FIELD_DROPDOWN = 'dropdown'; /** @type {string} */ +/// ARIA type name of an image field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_IMAGE = 'image'; +/** @type {string} */ +/// ARIA type name of an checkbox field, used by screen readers to identify the type of field. +Blockly.Msg.ARIA_TYPE_FIELD_CHECKBOX = 'checkbox'; +/** @type {string} */ /// Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. /// \n\nParameters:\n* %1 - the label of the field's value /// \n\nExamples:\n* "Edit 5"\n* "Edit item" @@ -1948,4 +1954,15 @@ Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1'; /// Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. /// \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 /// \n\nExamples:\n* "Option 1"\n* "Option 2" -Blockly.Msg.FIELD_LABEL_OPTION_INDEX = 'Option %1'; \ No newline at end of file +Blockly.Msg.FIELD_LABEL_OPTION_INDEX = 'Option %1'; +/** @type {string} */ +/// Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field. +Blockly.Msg.FIELD_LABEL_CHECKBOX_CHECKED = 'Checked'; +/** @type {string} */ +/// Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field. +Blockly.Msg.FIELD_LABEL_CHECKBOX_UNCHECKED = 'Not checked'; +/** @type {string} */ +/// Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. +/// \n\nParameters:\n* %1 - the name of the variable represented by the option +/// \n\nExamples:\n* 'Variable "item"'\n* 'Variable "x"' +Blockly.Msg.FIELD_LABEL_VARIABLE = 'Variable "%1"'; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/field_checkbox_test.js b/packages/blockly/tests/mocha/field_checkbox_test.js index c639f3581..2c5a249e9 100644 --- a/packages/blockly/tests/mocha/field_checkbox_test.js +++ b/packages/blockly/tests/mocha/field_checkbox_test.js @@ -293,4 +293,50 @@ suite('Checkbox Fields', function () { this.assertValue(false); }); }); + + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + this.block = this.workspace.newBlock('test_fields_checkbox'); + this.field = this.block.getField('CHECKBOX'); + this.block.initSvg(); + this.block.render(); + + this.focusableElement = this.field.getClickTarget_(); + }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, 'checkbox'); + }); + test('Field ARIA label is type name', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.equal(fieldLabel, 'checkbox'); + }); + test('Field does not include value in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.isFalse(fieldLabel.toLowerCase().includes('checked')); + }); + test('Hidden when in a flyout', function () { + this.block.isInFlyout = true; + // Force recompute of ARIA label. + this.field.setValue(this.field.getValue()); + const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); + assert.equal(ariaHidden, 'true'); + }); + test('Focusable element has role of checkbox', function () { + const role = this.focusableElement.getAttribute('role'); + assert.equal(role, 'checkbox'); + }); + test('Focusable element has correct default ARIA checked state', function () { + const ariaChecked = this.focusableElement.getAttribute('aria-checked'); + assert.equal(ariaChecked, 'true'); + }); + test('Focusable element updates ARIA checked state on setValue', function () { + this.field.setValue(false); + const ariaChecked = this.focusableElement.getAttribute('aria-checked'); + assert.equal(ariaChecked, 'false'); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_dropdown_test.js b/packages/blockly/tests/mocha/field_dropdown_test.js index c8d02c480..37af2b277 100644 --- a/packages/blockly/tests/mocha/field_dropdown_test.js +++ b/packages/blockly/tests/mocha/field_dropdown_test.js @@ -345,6 +345,10 @@ suite('Dropdown Fields', function () { const blockLabel = this.block.getAriaLabel(); assert.include(blockLabel, 'dropdown:'); }); + test('Field has field type name in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'dropdown:'); + }); test('Focusable element has role of button', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'button'); @@ -479,7 +483,7 @@ suite('Dropdown Fields', function () { const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'A'); + assert.include(label, 'A'); }); test('Image ARIA label is prioritized over alt text', function () { this.field.dropdownCreate(); @@ -487,7 +491,7 @@ suite('Dropdown Fields', function () { const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Letter B'); + assert.include(label, 'Letter B'); }); }); suite('Dropdown with HTMLElement options', function () { @@ -549,35 +553,35 @@ suite('Dropdown Fields', function () { const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Explicit A label'); + assert.include(label, 'Explicit A label'); }); test('HTMLElement ariaLabel prioritized over other properties', function () { this.field.setValue('B'); const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Element ARIA'); + assert.include(label, 'Element ARIA'); }); test('HTMLElement title is used when ariaLabel is missing', function () { this.field.setValue('C'); const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Title text'); + assert.include(label, 'Title text'); }); test('HTMLElement innerText is used as final fallback', function () { this.field.setValue('D'); const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Inner text'); + assert.include(label, 'Inner text'); }); test('Empty label falls back to option index', function () { this.field.setValue('E'); const label = this.field .getFocusableElement() .getAttribute('aria-label'); - assert.equal(label, 'Option 5'); + assert.include(label, 'Option 5'); }); }); }); diff --git a/packages/blockly/tests/mocha/field_image_test.js b/packages/blockly/tests/mocha/field_image_test.js index f0358703b..c4150d42a 100644 --- a/packages/blockly/tests/mocha/field_image_test.js +++ b/packages/blockly/tests/mocha/field_image_test.js @@ -348,4 +348,55 @@ suite('Image Fields', function () { }); }); }); + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + }); + suite('Image without click handler', function () { + setup(function () { + this.block = this.workspace.newBlock('text'); + this.field = this.block.inputList[0].fieldRow[0]; + this.block.initSvg(); + this.block.render(); + this.focusableElement = this.field.getFocusableElement(); + }); + test('Block has field type name in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, 'image:'); + }); + test('Field has field type name in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'image:'); + }); + test('Block has image alt text in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, this.field.altText); + }); + test('Focusable element has role of presentation', function () { + const role = this.focusableElement.getAttribute('role'); + assert.equal(role, 'presentation'); + }); + test('Hidden when in a flyout', function () { + this.block.isInFlyout = true; + // Force recompute of ARIA label. + this.field.setValue(this.field.getValue()); + const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); + assert.equal(ariaHidden, 'true'); + }); + }); + suite('Image with click handler', function () { + test('Focusable element has role of button', function () { + const block = this.workspace.newBlock('test_images_clickhandler'); + const field = block.getField('IMAGE'); + block.initSvg(); + block.render(); + + const focusableElement = field.getFocusableElement(); + const role = focusableElement.getAttribute('role'); + assert.equal(role, 'button'); + }); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_label_test.js b/packages/blockly/tests/mocha/field_label_test.js index bae600aff..6e5cd1522 100644 --- a/packages/blockly/tests/mocha/field_label_test.js +++ b/packages/blockly/tests/mocha/field_label_test.js @@ -223,4 +223,20 @@ suite('Label Fields', function () { }); }); }); + + suite('ARIA', function () { + test('Is hidden', function () { + const workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + const block = workspace.newBlock('text_print'); + const field = block.inputList[0].fieldRow[0]; + block.initSvg(); + block.render(); + + const focusableElement = field.getFocusableElement(); + const ariaHidden = focusableElement.getAttribute('aria-hidden'); + assert.equal(ariaHidden, 'true'); + }); + }); }); diff --git a/packages/blockly/tests/mocha/field_number_test.js b/packages/blockly/tests/mocha/field_number_test.js index 918bf3917..f040b5597 100644 --- a/packages/blockly/tests/mocha/field_number_test.js +++ b/packages/blockly/tests/mocha/field_number_test.js @@ -518,6 +518,10 @@ suite('Number Fields', function () { const blockLabel = this.block.getAriaLabel(); assert.include(blockLabel, 'number:'); }); + test('Field has field type name in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'number:'); + }); test('Focusable element has role of button', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'button'); diff --git a/packages/blockly/tests/mocha/field_textinput_test.js b/packages/blockly/tests/mocha/field_textinput_test.js index ab3fca359..5a1191435 100644 --- a/packages/blockly/tests/mocha/field_textinput_test.js +++ b/packages/blockly/tests/mocha/field_textinput_test.js @@ -609,6 +609,10 @@ suite('Text Input Fields', function () { const blockLabel = this.block.getAriaLabel(); assert.include(blockLabel, 'text:'); }); + test('Field has field type name in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'text:'); + }); test('Focusable element has role of button', function () { const role = this.focusableElement.getAttribute('role'); assert.equal(role, 'button'); diff --git a/packages/blockly/tests/mocha/field_variable_test.js b/packages/blockly/tests/mocha/field_variable_test.js index 270a662cf..c0cdcb669 100644 --- a/packages/blockly/tests/mocha/field_variable_test.js +++ b/packages/blockly/tests/mocha/field_variable_test.js @@ -204,9 +204,17 @@ suite('Variable Fields', function () { for (let i = 0, option; (option = expectedVarOptions[i]); i++) { assert.deepEqual(dropdownOptions[i], option); } - assert.include(dropdownOptions[dropdownOptions.length - 2][0], 'Rename'); + const varName = fieldVariable.getText(); - assert.include(dropdownOptions[dropdownOptions.length - 1][0], 'Delete'); + const renameLabel = dropdownOptions[dropdownOptions.length - 2][0]; + const deleteLabel = dropdownOptions[dropdownOptions.length - 1][0]; + + // Expect the rename and delete options to include the variable name. + assert.include(renameLabel, 'Rename'); + assert.include(renameLabel, varName); + + assert.include(deleteLabel, 'Delete'); + assert.include(deleteLabel, varName); }; test('Contains variables created before field', function () { this.workspace.getVariableMap().createVariable('name1', '', 'id1'); @@ -641,4 +649,75 @@ suite('Variable Fields', function () { assert.equal(variable.getId(), 'id2'); }); }); + suite('ARIA', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + this.block = this.workspace.newBlock('variables_set'); + this.field = this.block.getField('VAR'); + this.block.initSvg(); + this.block.render(); + + this.focusableElement = this.field.getFocusableElement(); + }); + test('Block has dropdown field type name and "Variable" qualifier in ARIA label', function () { + const blockLabel = this.block.getAriaLabel(); + assert.include(blockLabel, 'dropdown:'); + }); + test('Field has dropdown field type name and "Variable" qualifier in ARIA label', function () { + const fieldLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(fieldLabel, 'dropdown:'); + assert.include(fieldLabel, 'Variable'); + }); + test('Focusable element has role of button', function () { + const role = this.focusableElement.getAttribute('role'); + assert.equal(role, 'button'); + }); + test('Hidden when in a flyout', function () { + this.block.isInFlyout = true; + // Force recompute of ARIA label. + this.field.setValue(this.field.getValue()); + const ariaHidden = this.focusableElement.getAttribute('aria-hidden'); + assert.equal(ariaHidden, 'true'); + }); + test('Does not have aria-expanded when dropdown is closed', function () { + const ariaExpanded = this.focusableElement.getAttribute('aria-expanded'); + assert.equal(ariaExpanded, 'false'); + }); + test('Has aria-expanded when dropdown is open', function () { + this.field.showEditor_(); + const ariaExpanded = this.focusableElement.getAttribute('aria-expanded'); + assert.equal(ariaExpanded, 'true'); + this.workspace.hideChaff(); + }); + test('Has aria-haspopup of listbox', function () { + const ariaHasPopup = this.focusableElement.getAttribute('aria-haspopup'); + assert.equal(ariaHasPopup, 'listbox'); + }); + test('Has aria-controls that matches the ID of the dropdown menu', function () { + this.field.showEditor_(); + const ariaControls = this.focusableElement.getAttribute('aria-controls'); + const menuId = this.field.menu_.id; + assert.equal(ariaControls, menuId); + this.workspace.hideChaff(); + }); + test('Has placeholder ARIA label by default', function () { + const label = this.focusableElement.getAttribute('aria-label'); + assert.include(label, 'item'); + }); + test('New selected option updates ARIA label', function () { + const initialLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(initialLabel, 'item'); + + const newVariable = this.workspace + .getVariableMap() + .createVariable('newVar'); + this.field.getOptions(false); // Invalidate cached options. + this.field.setValue(newVariable.getId()); + + const updatedLabel = this.focusableElement.getAttribute('aria-label'); + assert.include(updatedLabel, 'newVar'); + }); + }); }); From a5ff158b1f87907760b646d58739eaa4b47c4ba4 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 29 Apr 2026 09:20:35 -0700 Subject: [PATCH 075/200] fix: Prevent errors when mixing keyboard/mouse input in the toolbox/flyout (#9773) --- .../interfaces/i_collapsible_toolbox_item.ts | 1 + .../navigators/toolbox_navigator.ts | 5 ++++- packages/blockly/core/toolbox/toolbox.ts | 19 +++++++++++++++---- packages/blockly/tests/mocha/toolbox_test.js | 1 + 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts b/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts index 6e29e5843..214847d7f 100644 --- a/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts +++ b/packages/blockly/core/interfaces/i_collapsible_toolbox_item.ts @@ -42,6 +42,7 @@ export function isCollapsibleToolboxItem( obj: any, ): obj is ICollapsibleToolboxItem { return ( + obj && typeof obj.getChildToolboxItems === 'function' && typeof obj.isExpanded === 'function' && typeof obj.toggleExpanded === 'function' && diff --git a/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts index 542ecca0b..808ac9bc8 100644 --- a/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/toolbox_navigator.ts @@ -45,7 +45,10 @@ export class ToolboxNavigator extends Navigator { } } - if (isSelectableToolboxItem(node) && !node.getContents().length) { + if ( + !isSelectableToolboxItem(node) || + (isSelectableToolboxItem(node) && !node.getContents().length) + ) { return null; } diff --git a/packages/blockly/core/toolbox/toolbox.ts b/packages/blockly/core/toolbox/toolbox.ts index 22d5056be..d87500fc6 100644 --- a/packages/blockly/core/toolbox/toolbox.ts +++ b/packages/blockly/core/toolbox/toolbox.ts @@ -277,22 +277,33 @@ export class Toolbox * @param e Click event to handle. */ protected onClick_(e: PointerEvent) { + const close = () => { + getFocusManager().focusNode(this); + this.clearSelection(); + (common.getMainWorkspace() as WorkspaceSvg).hideChaff(false); + e.preventDefault(); + }; + this.mouseDown = true; if (browserEvents.isRightButton(e) || e.target === this.HtmlDiv) { // Close flyout. - (common.getMainWorkspace() as WorkspaceSvg).hideChaff(false); + close(); } else { const targetElement = e.target; const itemId = (targetElement as Element).getAttribute('id'); if (itemId) { const item = this.getToolboxItemById(itemId); if (item?.isSelectable()) { - this.setSelectedItem(item); (item as ISelectableToolboxItem).onClick(e); + if (item === this.getSelectedItem()) { + close(); + } else { + this.setSelectedItem(item); + // Just close popups. + (common.getMainWorkspace() as WorkspaceSvg).hideChaff(true); + } } } - // Just close popups. - (common.getMainWorkspace() as WorkspaceSvg).hideChaff(true); } Touch.clearTouchIdentifier(); } diff --git a/packages/blockly/tests/mocha/toolbox_test.js b/packages/blockly/tests/mocha/toolbox_test.js index c02f09f4d..000183b1d 100644 --- a/packages/blockly/tests/mocha/toolbox_test.js +++ b/packages/blockly/tests/mocha/toolbox_test.js @@ -234,6 +234,7 @@ suite('Toolbox', function () { )[0]; const evt = { 'target': categoryXml, + 'stopPropagation': () => {}, }; const item = this.toolbox.contents.get(categoryXml.getAttribute('id')); const setSelectedSpy = sinon.spy(this.toolbox, 'setSelectedItem'); From b046d0d21137ff7e491d9ae1130ad2b35619c8e5 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 29 Apr 2026 12:43:22 -0400 Subject: [PATCH 076/200] chore: make key messages optional --- packages/blockly/msg/json/en.json | 2 +- packages/blockly/msg/json/qqq.json | 38 +++++++++++++++--------------- packages/blockly/msg/messages.js | 38 +++++++++++++++--------------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index caf6c4986..ba25a150d 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-29 08:57:47.670420", + "lastupdated": "2026-04-29 12:42:30.774691", "locale": "en", "messagedocumentation" : "qqq" }, diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 6531af1ef..4844a14c0 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -413,25 +413,25 @@ "CHROME_OS": "Name of the Google ChromeOS operating system displayed in a list of keyboard shortcuts.", "LINUX": "Name of the GNU/Linux operating system displayed in a list of keyboard shortcuts.", "UNKNOWN": "Placeholder name for an operating system that can't be identified in a list of keyboard shortcuts.", - "CONTROL_KEY": "Representation of the Control key used in keyboard shortcuts.", - "COMMAND_KEY": "Representation of the Mac Command key used in keyboard shortcuts.", - "OPTION_KEY": "Representation of the Mac Option key used in keyboard shortcuts.", - "ALT_KEY": "Representation of the Alt key used in keyboard shortcuts.", - "ENTER_KEY": "Representation of the Enter key used in keyboard shortcuts.", - "BACKSPACE_KEY": "Representation of the Backspace key used in keyboard shortcuts.", - "DELETE_KEY": "Representation of the Delete key used in keyboard shortcuts.", - "ESCAPE": "Representation of the Escape key used in keyboard shortcuts.", - "TAB_KEY": "Representation of the Tab key used in keyboard shortcuts.", - "SHIFT_KEY": "Representation of the Shift key used in keyboard shortcuts.", - "CAPS_LOCK_KEY": "Representation of the Caps Lock key used in keyboard shortcuts.", - "SPACE_KEY": "Representation of the Space key used in keyboard shortcuts.", - "PAGE_UP_KEY": "Representation of the Page Up key used in keyboard shortcuts.", - "PAGE_DOWN_KEY": "Representation of the Page Down key used in keyboard shortcuts.", - "END_KEY": "Representation of the End key used in keyboard shortcuts.", - "HOME_KEY": "Representation of the Home key used in keyboard shortcuts.", - "INSERT_KEY": "Representation of the Insert key used in keyboard shortcuts.", - "PAUSE_KEY": "Representation of the Pause key used in keyboard shortcuts.", - "CONTEXT_MENU_KEY": "Representation of the Context Menu key used in keyboard shortcuts.", + "CONTROL_KEY": "{{Optional}} Representation of the Control key used in keyboard shortcuts.", + "COMMAND_KEY": "{{Optional}} Representation of the Mac Command key used in keyboard shortcuts.", + "OPTION_KEY": "{{Optional}} Representation of the Mac Option key used in keyboard shortcuts.", + "ALT_KEY": "{{Optional}} Representation of the Alt key used in keyboard shortcuts.", + "ENTER_KEY": "{{Optional}} Representation of the Enter key used in keyboard shortcuts.", + "BACKSPACE_KEY": "{{Optional}} Representation of the Backspace key used in keyboard shortcuts.", + "DELETE_KEY": "{{Optional}} Representation of the Delete key used in keyboard shortcuts.", + "ESCAPE": "{{Optional}} Representation of the Escape key used in keyboard shortcuts.", + "TAB_KEY": "{{Optional}} Representation of the Tab key used in keyboard shortcuts.", + "SHIFT_KEY": "{{Optional}} Representation of the Shift key used in keyboard shortcuts.", + "CAPS_LOCK_KEY": "{{Optional}} Representation of the Caps Lock key used in keyboard shortcuts.", + "SPACE_KEY": "{{Optional}} Representation of the Space key used in keyboard shortcuts.", + "PAGE_UP_KEY": "{{Optional}} Representation of the Page Up key used in keyboard shortcuts.", + "PAGE_DOWN_KEY": "{{Optional}} Representation of the Page Down key used in keyboard shortcuts.", + "END_KEY": "{{Optional}} Representation of the End key used in keyboard shortcuts.", + "HOME_KEY": "{{Optional}} Representation of the Home key used in keyboard shortcuts.", + "INSERT_KEY": "{{Optional}} Representation of the Insert key used in keyboard shortcuts.", + "PAUSE_KEY": "{{Optional}} Representation of the Pause key used in keyboard shortcuts.", + "CONTEXT_MENU_KEY": "{{Optional}} Representation of the Context Menu key used in keyboard shortcuts.", "CUT_SHORTCUT": "menu label - Contextual menu item that cuts the focused item.", "COPY_SHORTCUT": "menu label - Contextual menu item that copies the focused item.", "PASTE_SHORTCUT": "menu label - Contextual menu item that pastes the previously copied item.", diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index fb353964a..829083d43 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1646,61 +1646,61 @@ Blockly.Msg.LINUX = 'Linux'; /// of keyboard shortcuts. Blockly.Msg.UNKNOWN = 'Unknown'; /** @type {string} */ -/// Representation of the Control key used in keyboard shortcuts. +/// {{Optional}} Representation of the Control key used in keyboard shortcuts. Blockly.Msg.CONTROL_KEY = 'Ctrl'; /** @type {string} */ -/// Representation of the Mac Command key used in keyboard shortcuts. +/// {{Optional}} Representation of the Mac Command key used in keyboard shortcuts. Blockly.Msg.COMMAND_KEY = '⌘ Command'; /** @type {string} */ -/// Representation of the Mac Option key used in keyboard shortcuts. +/// {{Optional}} Representation of the Mac Option key used in keyboard shortcuts. Blockly.Msg.OPTION_KEY = '⌥ Option'; /** @type {string} */ -/// Representation of the Alt key used in keyboard shortcuts. +/// {{Optional}} Representation of the Alt key used in keyboard shortcuts. Blockly.Msg.ALT_KEY = 'Alt'; /** @type {string} */ -/// Representation of the Enter key used in keyboard shortcuts. +/// {{Optional}} Representation of the Enter key used in keyboard shortcuts. Blockly.Msg.ENTER_KEY = 'Enter'; /** @type {string} */ -/// Representation of the Backspace key used in keyboard shortcuts. +/// {{Optional}} Representation of the Backspace key used in keyboard shortcuts. Blockly.Msg.BACKSPACE_KEY = 'Backspace'; /** @type {string} */ -/// Representation of the Delete key used in keyboard shortcuts. +/// {{Optional}} Representation of the Delete key used in keyboard shortcuts. Blockly.Msg.DELETE_KEY = 'Delete'; /** @type {string} */ -/// Representation of the Escape key used in keyboard shortcuts. +/// {{Optional}} Representation of the Escape key used in keyboard shortcuts. Blockly.Msg.ESCAPE = 'Esc'; /** @type {string} */ -/// Representation of the Tab key used in keyboard shortcuts. +/// {{Optional}} Representation of the Tab key used in keyboard shortcuts. Blockly.Msg.TAB_KEY = 'Tab'; /** @type {string} */ -/// Representation of the Shift key used in keyboard shortcuts. +/// {{Optional}} Representation of the Shift key used in keyboard shortcuts. Blockly.Msg.SHIFT_KEY = 'Shift'; /** @type {string} */ -/// Representation of the Caps Lock key used in keyboard shortcuts. +/// {{Optional}} Representation of the Caps Lock key used in keyboard shortcuts. Blockly.Msg.CAPS_LOCK_KEY = 'Caps Lock'; /** @type {string} */ -/// Representation of the Space key used in keyboard shortcuts. +/// {{Optional}} Representation of the Space key used in keyboard shortcuts. Blockly.Msg.SPACE_KEY = 'Space'; /** @type {string} */ -/// Representation of the Page Up key used in keyboard shortcuts. +/// {{Optional}} Representation of the Page Up key used in keyboard shortcuts. Blockly.Msg.PAGE_UP_KEY = 'Page Up'; /** @type {string} */ -/// Representation of the Page Down key used in keyboard shortcuts. +/// {{Optional}} Representation of the Page Down key used in keyboard shortcuts. Blockly.Msg.PAGE_DOWN_KEY = 'Page Down'; /** @type {string} */ -/// Representation of the End key used in keyboard shortcuts. +/// {{Optional}} Representation of the End key used in keyboard shortcuts. Blockly.Msg.END_KEY = 'End'; /** @type {string} */ -/// Representation of the Home key used in keyboard shortcuts. +/// {{Optional}} Representation of the Home key used in keyboard shortcuts. Blockly.Msg.HOME_KEY = 'Home'; /** @type {string} */ -/// Representation of the Insert key used in keyboard shortcuts. +/// {{Optional}} Representation of the Insert key used in keyboard shortcuts. Blockly.Msg.INSERT_KEY = 'Insert'; /** @type {string} */ -/// Representation of the Pause key used in keyboard shortcuts. +/// {{Optional}} Representation of the Pause key used in keyboard shortcuts. Blockly.Msg.PAUSE_KEY = 'Pause'; /** @type {string} */ -/// Representation of the Context Menu key used in keyboard shortcuts. +/// {{Optional}} Representation of the Context Menu key used in keyboard shortcuts. Blockly.Msg.CONTEXT_MENU_KEY = '≣ Menu'; /** @type {string} */ /// menu label - Contextual menu item that cuts the focused item. From 903f9d2b4e9cc1ad2bb1ccb5db1abc1ef0160ef9 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 29 Apr 2026 12:52:48 -0400 Subject: [PATCH 077/200] chore: update google closure compiler because it hates us --- package-lock.json | 373 ++++++---------------------------- packages/blockly/package.json | 2 +- 2 files changed, 60 insertions(+), 315 deletions(-) diff --git a/package-lock.json b/package-lock.json index edb1fffba..cd904b4cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13574,12 +13574,14 @@ } }, "node_modules/google-closure-compiler": { - "version": "20260330.0.0", + "version": "20260427.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20260427.0.0.tgz", + "integrity": "sha512-Glk0eQaOO+r7SFFx+UrJCGZX4kGf5l3eonRvkImR7Jgh4acTcqXoY+yvquULRxptXMews+sO/VlwK1mIRJoeyg==", "dev": true, "license": "Apache-2.0", "dependencies": { "chalk": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 <5.6.1 || ^5.6.2 >5.6.1", - "google-closure-compiler-java": "^20260330.0.0", + "google-closure-compiler-java": "^20260427.0.0", "minimist": "^1.0.0", "vinyl": "^3.0.1", "vinyl-sourcemaps-apply": "^0.2.0" @@ -13591,19 +13593,52 @@ "node": ">=18" }, "optionalDependencies": { - "google-closure-compiler-linux": "^20260330.0.0", - "google-closure-compiler-linux-arm64": "^20260330.0.0", - "google-closure-compiler-macos": "^20260330.0.0", - "google-closure-compiler-windows": "^20260330.0.0" + "google-closure-compiler-linux": "^20260427.0.0", + "google-closure-compiler-linux-arm64": "^20260427.0.0", + "google-closure-compiler-macos": "^20260427.0.0", + "google-closure-compiler-windows": "^20260427.0.0" } }, "node_modules/google-closure-compiler-java": { - "version": "20260330.0.0", + "version": "20260427.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20260427.0.0.tgz", + "integrity": "sha512-qQt4c5x91/c42nfyBIvIah9KLAQBSBslSLS7SOk8dwGy3qpMlc+QKiU4s0MQaz+29NociWzhAznoYBhIl5WT3w==", "dev": true, "license": "Apache-2.0" }, + "node_modules/google-closure-compiler-linux": { + "version": "20260427.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20260427.0.0.tgz", + "integrity": "sha512-Ba2pFIt0DK9HMLLV3kXEX5L4RRsuNGtTY/JBIfasLuo7HpA6zU57VZ/e/54pVf/GHvnRmPHExUtinMEbcCSPTg==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/google-closure-compiler-linux-arm64": { + "version": "20260427.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20260427.0.0.tgz", + "integrity": "sha512-XhwrmAvyKhCQzr+7n74ewW4JuxT4JciyuLPDi9NblPQJWiRtvzQ83Dga01XnaSHIaE3CNHHILZ/Tftt/sh3sjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/google-closure-compiler-macos": { - "version": "20260330.0.0", + "version": "20260427.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20260427.0.0.tgz", + "integrity": "sha512-kXRGOf7xu67BEXYmIRedZN5hGpjZty1xakRSPxKR2ZO9Q5CxZsttrR5K/Mxx578nBFtBomq7dZ+5FwuTz1Usyg==", "cpu": [ "arm64" ], @@ -13614,6 +13649,21 @@ "darwin" ] }, + "node_modules/google-closure-compiler-windows": { + "version": "20260427.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20260427.0.0.tgz", + "integrity": "sha512-JD0AGj/sT/6HPU74IdsEgpq7ejypXotLlXHIKOAZpm9/lIZ6QsSovcP6m3Mg9TJ4JIJzA965Iph3hXp6KSNgag==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/gopd": { "version": "1.2.0", "license": "MIT", @@ -24652,7 +24702,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260330.0.0", + "google-closure-compiler": "^20260427.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", @@ -24852,23 +24902,6 @@ "node": ">=20.11.0" } }, - "packages/blockly/node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, "packages/blockly/node_modules/@gulp-sourcemaps/identity-map": { "version": "2.0.1", "dev": true, @@ -25778,17 +25811,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "packages/blockly/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "packages/blockly/node_modules/@wdio/config": { "version": "9.14.0", "dev": true, @@ -25995,14 +26017,6 @@ "node": ">=6.5" } }, - "packages/blockly/node_modules/agent-base": { - "version": "7.1.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "packages/blockly/node_modules/ajv-draft-04": { "version": "1.0.0", "dev": true, @@ -26594,32 +26608,6 @@ "url": "https://github.com/cheeriojs/cheerio?sponsor=1" } }, - "packages/blockly/node_modules/chokidar": { - "version": "3.5.3", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "packages/blockly/node_modules/chromium-bidi": { "version": "8.0.0", "dev": true, @@ -27025,22 +27013,6 @@ "node": ">= 12" } }, - "packages/blockly/node_modules/debug": { - "version": "4.4.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "packages/blockly/node_modules/debug-fabulous": { "version": "1.1.0", "dev": true, @@ -27128,19 +27100,6 @@ "node": ">=0.3.1" } }, - "packages/blockly/node_modules/domutils": { - "version": "3.1.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, "packages/blockly/node_modules/each-props": { "version": "3.0.0", "dev": true, @@ -27433,28 +27392,6 @@ } } }, - "packages/blockly/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "packages/blockly/node_modules/esquery": { - "version": "1.6.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, "packages/blockly/node_modules/event-emitter": { "version": "0.3.5", "dev": true, @@ -27571,14 +27508,6 @@ "node": ">= 4.9.1" } }, - "packages/blockly/node_modules/fastq": { - "version": "1.13.0", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "packages/blockly/node_modules/fd-slicer": { "version": "1.1.0", "dev": true, @@ -27886,20 +27815,6 @@ "node": ">= 10.13.0" } }, - "packages/blockly/node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "packages/blockly/node_modules/global-modules": { "version": "1.0.0", "dev": true, @@ -28288,17 +28203,6 @@ "node": ">= 10.13.0" } }, - "packages/blockly/node_modules/hasown": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "packages/blockly/node_modules/homedir-polyfill": { "version": "1.0.3", "dev": true, @@ -28344,18 +28248,6 @@ "entities": "^4.5.0" } }, - "packages/blockly/node_modules/http-proxy-agent": { - "version": "7.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "packages/blockly/node_modules/http-server": { "version": "14.1.1", "dev": true, @@ -28382,18 +28274,6 @@ "node": ">=12" } }, - "packages/blockly/node_modules/https-proxy-agent": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "packages/blockly/node_modules/iconv-lite": { "version": "0.6.3", "dev": true, @@ -28471,17 +28351,6 @@ "node": ">=0.10.0" } }, - "packages/blockly/node_modules/is-core-module": { - "version": "2.13.1", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "packages/blockly/node_modules/is-extendable": { "version": "1.0.1", "dev": true, @@ -28645,17 +28514,6 @@ "node": ">= 4" } }, - "packages/blockly/node_modules/jsonfile": { - "version": "6.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "packages/blockly/node_modules/jsonify": { "version": "0.0.1", "dev": true, @@ -29123,11 +28981,6 @@ "dev": true, "license": "MIT" }, - "packages/blockly/node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, "packages/blockly/node_modules/mute-stdout": { "version": "2.0.0", "dev": true, @@ -29332,29 +29185,6 @@ "dev": true, "license": "MIT" }, - "packages/blockly/node_modules/parse5": { - "version": "7.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^4.5.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "packages/blockly/node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "domhandler": "^5.0.2", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "packages/blockly/node_modules/parse5-parser-stream": { "version": "7.1.2", "dev": true, @@ -29434,19 +29264,6 @@ "node": ">=0.10.0" } }, - "packages/blockly/node_modules/path-to-regexp": { - "version": "1.8.0", - "dev": true, - "license": "MIT", - "dependencies": { - "isarray": "0.0.1" - } - }, - "packages/blockly/node_modules/path-to-regexp/node_modules/isarray": { - "version": "0.0.1", - "dev": true, - "license": "MIT" - }, "packages/blockly/node_modules/pend": { "version": "1.2.0", "dev": true, @@ -29634,20 +29451,6 @@ "node": ">=18" } }, - "packages/blockly/node_modules/qs": { - "version": "6.14.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "packages/blockly/node_modules/query-selector-shadow-dom": { "version": "1.0.1", "dev": true, @@ -29739,22 +29542,6 @@ "util-deprecate": "~1.0.1" } }, - "packages/blockly/node_modules/resolve": { - "version": "1.22.8", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "packages/blockly/node_modules/resolve-dir": { "version": "1.0.1", "dev": true, @@ -30034,20 +29821,6 @@ "semver": "bin/semver.js" } }, - "packages/blockly/node_modules/synckit": { - "version": "0.11.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.4" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, "packages/blockly/node_modules/tar-fs": { "version": "3.1.1", "dev": true, @@ -30526,14 +30299,6 @@ "node": ">=12" } }, - "packages/blockly/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "packages/blockly/node_modules/workerpool": { "version": "9.3.2", "dev": true, @@ -30544,26 +30309,6 @@ "dev": true, "license": "ISC" }, - "packages/blockly/node_modules/ws": { - "version": "8.18.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "packages/blockly/node_modules/xtend": { "version": "4.0.2", "dev": true, diff --git a/packages/blockly/package.json b/packages/blockly/package.json index d32aa0c21..aa3e82000 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -123,7 +123,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20260330.0.0", + "google-closure-compiler": "^20260427.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", From 61ad99440c478d73ad834c9461d117a0eafbf425 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 29 Apr 2026 13:50:44 -0400 Subject: [PATCH 078/200] feat: make flyouts and workspace labels properly accessible (#9774) * feat: make flyouts and workspace labels properly accessible * chore: update tests * chore: small refactor --- packages/blockly/core/block_aria_composer.ts | 6 +- .../blockly/core/block_flyout_inflater.ts | 15 ++++- packages/blockly/core/flyout_base.ts | 52 +++++++++++++++ packages/blockly/core/flyout_button.ts | 12 +++- .../core/interfaces/i_flyout_inflater.ts | 9 +++ packages/blockly/core/utils/aria.ts | 7 ++ packages/blockly/core/utils/dom.ts | 4 +- packages/blockly/core/workspace_svg.ts | 64 +++++++++++++++++-- packages/blockly/msg/json/en.json | 9 ++- packages/blockly/msg/json/qqq.json | 11 +++- packages/blockly/msg/messages.js | 35 ++++++++-- packages/blockly/tests/mocha/utils_test.js | 12 ++-- 12 files changed, 207 insertions(+), 29 deletions(-) diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 03e53458b..c2b661163 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -81,7 +81,11 @@ export function computeAriaLabel( export function configureAriaRole(block: BlockSvg) { setRole(block.getSvgRoot(), Role.PRESENTATION); const focusableElement = block.getFocusableElement(); - setRole(focusableElement, block.isInFlyout ? Role.LISTITEM : Role.FIGURE); + if (!block.isInFlyout) { + // blocks in the flyout have their role set by the Flyout's block inflater + // don't overwrite it here + setRole(focusableElement, Role.FIGURE); + } let roleDescription = Msg['BLOCK_LABEL_STATEMENT']; if (block.statementInputCount) { diff --git a/packages/blockly/core/block_flyout_inflater.ts b/packages/blockly/core/block_flyout_inflater.ts index 80f868551..710e4efd3 100644 --- a/packages/blockly/core/block_flyout_inflater.ts +++ b/packages/blockly/core/block_flyout_inflater.ts @@ -15,6 +15,7 @@ import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; import * as registry from './registry.js'; import * as blocks from './serialization/blocks.js'; +import {aria} from './utils.js'; import type {BlockInfo} from './utils/toolbox.js'; import * as utilsXml from './utils/xml.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -67,7 +68,19 @@ export class BlockFlyoutInflater implements IFlyoutInflater { // Mark blocks as being inside a flyout. This is used to detect and // prevent the closure of the flyout if the user right-clicks on such // a block. - block.getDescendants(false).forEach((b) => (b.isInFlyout = true)); + block.getDescendants(false).forEach((b) => { + b.isInFlyout = true; + const focusableElement = b.getFocusableElement(); + // blocks can't be focused if they're in a flyout and not top-level + // nonfocusable blocks should be hidden from the aria tree + aria.setState(focusableElement, aria.State.HIDDEN, true); + aria.setRole(focusableElement, aria.Role.PRESENTATION); + }); + // Since getDescencdants includes the root block, we need + // to correct the role and hidden state for it. + const focusableElement = block.getFocusableElement(); + aria.clearState(focusableElement, aria.State.HIDDEN); + aria.setRole(focusableElement, aria.Role.LISTITEM); this.addBlockListeners(block); return new FlyoutItem(block, BLOCK_TYPE); diff --git a/packages/blockly/core/flyout_base.ts b/packages/blockly/core/flyout_base.ts index eab100253..c973b1775 100644 --- a/packages/blockly/core/flyout_base.ts +++ b/packages/blockly/core/flyout_base.ts @@ -24,12 +24,15 @@ import {getFocusManager} from './focus_manager.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import {isSelectableToolboxItem} from './interfaces/i_selectable_toolbox_item.js'; import {FlyoutNavigator} from './keyboard_nav/navigators/flyout_navigator.js'; +import {Msg} from './msg.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import * as renderManagement from './render_management.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import {SEPARATOR_TYPE} from './separator_flyout_inflater.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import * as idGenerator from './utils/idgenerator.js'; import {Svg} from './utils/svg.js'; @@ -312,6 +315,7 @@ export abstract class Flyout init(targetWorkspace: WorkspaceSvg) { this.targetWorkspace = targetWorkspace; this.workspace_.targetWorkspace = targetWorkspace; + this.workspace_.setInitialAriaContext(); this.workspace_.scrollbar = new ScrollbarPair( this.workspace_, @@ -632,6 +636,7 @@ export abstract class Flyout this.width_ = 0; } this.reflow(); + this.updateAriaContext(); eventUtils.setRecordUndo(true); this.workspace_.setResizesEnabled(true); @@ -650,6 +655,53 @@ export abstract class Flyout this.workspace_.addChangeListener(this.reflowWrapper); } + /** + * Updates the aria attributes for the entire flyout dom. + * This needs to do two things: + * 1. Set aria-owns on the flyout's workspace canvas to include the ids of all + * focusable elements in the flyout. + * 2. Update the aria attributes on the flyout's workspace. This can't be done at workspace + * creation because the workspace may not have all required information until the flyout + * is fully shown. + */ + protected updateAriaContext() { + // Set aria-owns on the flyout's workspace canvas to include the ids of all focusable elements in the flyout. + // This is probably not necessary if the listitems are all direct descendants of the canvas, but + // we can't know the dom structure of the flyout contents, so it's best to be explicit. + const focusableIds = this.getContents() + .map((item) => item.getElement()) + .filter((item) => item.canBeFocused()) + .map((item) => item.getFocusableElement().id); + aria.setState( + this.getWorkspace().getCanvas(), + aria.State.OWNS, + focusableIds.join(' '), + ); + + // Update aria attributes on the flyout's workspace. + // Only call a flyout's workspace a region if it's not auto-closing and not a mutator + if (!this.targetWorkspace.isMutator && !this.autoClose) { + aria.setRole(this.getWorkspace().svgGroup_, aria.Role.REGION); + } else { + aria.setRole(this.getWorkspace().svgGroup_, aria.Role.PRESENTATION); + } + + // the label for a flyout includes the category name if it's available + const selectedItem = this.targetWorkspace.getToolbox()?.getSelectedItem(); + const selectedItemName = + selectedItem && isSelectableToolboxItem(selectedItem) + ? selectedItem.getName() + : ''; + const ariaLabel = Msg['WORKSPACE_LABEL_FLYOUT_WORKSPACE'] + .replace('%1', selectedItemName) + .trim(); + aria.setState(this.getWorkspace().getCanvas(), aria.State.LABEL, ariaLabel); + + // The block canvas is a list. The list items must be direct descendants of the list, + // and the flyout may or may not be a region, so we set the role on the block canvas rather than the svgGroup_. + aria.setRole(this.getWorkspace().getCanvas(), aria.Role.LIST); + } + /** * Create the contents array and gaps array necessary to create the layout for * the flyout. diff --git a/packages/blockly/core/flyout_button.ts b/packages/blockly/core/flyout_button.ts index 3396258c6..00782a19a 100644 --- a/packages/blockly/core/flyout_button.ts +++ b/packages/blockly/core/flyout_button.ts @@ -17,7 +17,8 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js'; 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 {Msg} from './msg.js'; +import {aria, idGenerator} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -134,6 +135,7 @@ export class FlyoutButton }, this.svgGroup!, ); + aria.setRole(shadow, aria.Role.PRESENTATION); } // Background rectangle. const rect = dom.createSvgElement( @@ -147,6 +149,7 @@ export class FlyoutButton }, this.svgGroup!, ); + aria.setRole(rect, aria.Role.PRESENTATION); const svgText = dom.createSvgElement( Svg.TEXT, @@ -170,6 +173,13 @@ export class FlyoutButton .getThemeManager() .subscribe(this.svgText, 'flyoutForegroundColour', 'fill'); } + aria.setRole(svgText, aria.Role.PRESENTATION); + + // We add the word "heading" or "button" to the label so that they give appropriate hints + // we can't use the corresponding roles because that overwrites the context of it being a list item. + const ariaLabel = `${text}, ${this.isFlyoutLabel ? Msg['ARIA_LABEL_HEADING'] : Msg['ARIA_LABEL_BUTTON']}`; + aria.setState(this.getFocusableElement(), aria.State.LABEL, ariaLabel); + aria.setRole(this.getFocusableElement(), aria.Role.LISTITEM); const fontSize = style.getComputedStyle(svgText, 'fontSize'); const fontWeight = style.getComputedStyle(svgText, 'fontWeight'); diff --git a/packages/blockly/core/interfaces/i_flyout_inflater.ts b/packages/blockly/core/interfaces/i_flyout_inflater.ts index e3c1f5db4..d8d815282 100644 --- a/packages/blockly/core/interfaces/i_flyout_inflater.ts +++ b/packages/blockly/core/interfaces/i_flyout_inflater.ts @@ -8,6 +8,15 @@ export interface IFlyoutInflater { * Note that this method's interface is identical to that in ISerializer, to * allow for code reuse. * + * You must ensure that any item created by this method has the appropriate + * ARIA markup: + * - The role of the element's focusable element should be set to `listitem`. + * - The focusable element must have an `id` attribute. + * - Any DOM parents of the focusable element should set their role to + * `presentation` to avoid interfering with flyout list navigation. + * - If the element is not focusable, it must be hidden from the ARIA tree. + * Only do this if the content should be inaccessible to screenreaders. + * * @param state A JSON representation of an element to inflate on the flyout. * @param flyout The flyout on whose workspace the inflated element * should be created. If the inflated element is an `IRenderedElement` it diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 69e86549f..ebcb5ebf8 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -214,6 +214,13 @@ export enum State { * Value: a number representing the minimum allowed value for a range widget. */ VALUEMIN = 'valuemin', + + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-owns + * + * Value: a space-separated list of element IDs that are owned by the current element. + */ + OWNS = 'owns', } /** diff --git a/packages/blockly/core/utils/dom.ts b/packages/blockly/core/utils/dom.ts index 37bccb578..ea279080d 100644 --- a/packages/blockly/core/utils/dom.ts +++ b/packages/blockly/core/utils/dom.ts @@ -58,7 +58,7 @@ export function createSvgElement( ): T { const e = document.createElementNS(SVG_NS, `${name}`) as T; /** - * For svg and group (g) elements, we set the role to generic so that they are ignored by assistive technologies. + * For svg and group (g) elements, we set the role to presentation so that they are ignored by assistive technologies. */ if ( name === Svg.SVG.toString() || @@ -66,7 +66,7 @@ export function createSvgElement( e.tagName === Svg.SVG.toString() || e.tagName === Svg.G.toString() ) { - aria.setRole(e, aria.Role.GENERIC); + aria.setRole(e, aria.Role.PRESENTATION); } for (const key in attrs) { e.setAttribute(key, `${attrs[key]}`); diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 6b548647a..dd1afe50e 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -698,6 +698,53 @@ export class WorkspaceSvg this.resizeHandlerWrapper = handler; } + /** + * Sets Aria labels, roles, etc. for the workspace depending on the type of workspace it is. + */ + setInitialAriaContext() { + if (!this.svgGroup_) { + throw new Error( + 'Must initialize svgGroup_ by calling `createDom` before calling setAriaContext', + ); + } + if (this.isFlyout) { + // Flyouts have their aria attributes set when the flyout is shown. + return; + } + aria.setRole(this.svgGroup_, aria.Role.REGION); + if (this.isMutator) { + aria.setState( + this.svgGroup_, + aria.State.LABEL, + Msg['WORKSPACE_LABEL_MUTATOR_WORKSPACE'], + ); + } else { + // Main workspaces get labelled with how many stacks of blocks they contain + // This will be updated in a change listener, but set it here in case there are blocks in the initial state of the workspace + this.updateAriaLabel(); + } + } + + /** + * Updates the label on the workspace to reflect the number of top-level stacks in the workspace. + */ + private updateAriaLabel() { + const numStacks = this.getTopBlocks(false).length; + if (numStacks == 1) { + aria.setState( + this.svgGroup_, + aria.State.LABEL, + Msg['WORKSPACE_LABEL_1_STACK'], + ); + } else { + aria.setState( + this.svgGroup_, + aria.State.LABEL, + Msg['WORKSPACE_LABEL_MANY_STACKS'].replace('%1', String(numStacks)), + ); + } + } + /** * Create the workspace DOM elements. * @@ -722,13 +769,6 @@ export class WorkspaceSvg 'class': 'blocklyWorkspace', 'id': this.id, }); - if (injectionDiv) { - aria.setState( - this.svgGroup_, - aria.State.LABEL, - Msg['WORKSPACE_ARIA_LABEL'], - ); - } // 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 @@ -756,6 +796,16 @@ export class WorkspaceSvg this.svgBlockCanvas_ = this.layerManager.getBlockLayer(); this.svgBubbleCanvas_ = this.layerManager.getBubbleLayer(); + this.setInitialAriaContext(); + + if (!this.isFlyout && !this.isMutator) { + // Set up a change listener to update the aria label on main workspace + this.addChangeListener((e) => { + if (e.isUiEvent) return; + this.updateAriaLabel(); + }); + } + if (!this.isFlyout) { browserEvents.conditionalBind( this.svgGroup_, diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index ba25a150d..6abea93e2 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -394,7 +394,6 @@ "PROCEDURES_IFRETURN_HELPURL": "https://c2.com/cgi/wiki?GuardClause", "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", "COLLAPSED_WARNINGS_WARNING": "Collapsed blocks contain warnings.", "DIALOG_OK": "OK", "DIALOG_CANCEL": "Cancel", @@ -456,6 +455,10 @@ "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position", "KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.", "KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste.", + "WORKSPACE_LABEL_1_STACK": "Blocks workspace. 1 stack of blocks", + "WORKSPACE_LABEL_MANY_STACKS": "Blocks workspace. %1 stacks of blocks", + "WORKSPACE_LABEL_MUTATOR_WORKSPACE": "Block editor workspace", + "WORKSPACE_LABEL_FLYOUT_WORKSPACE": "%1 blocks", "WORKSPACE_CONTENTS_BLOCKS_MANY": "%1 stacks of blocks%2 in workspace.", "WORKSPACE_CONTENTS_BLOCKS_ONE": "One stack of blocks%2 in workspace.", "WORKSPACE_CONTENTS_BLOCKS_ZERO": "No blocks%2 in workspace.", @@ -494,5 +497,7 @@ "FIELD_LABEL_OPTION_INDEX": "Option %1", "FIELD_LABEL_CHECKBOX_CHECKED": "Checked", "FIELD_LABEL_CHECKBOX_UNCHECKED": "Not checked", - "FIELD_LABEL_VARIABLE": "Variable '%1'" + "FIELD_LABEL_VARIABLE": "Variable '%1'", + "ARIA_LABEL_BUTTON": "button", + "ARIA_LABEL_HEADING": "heading" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 4844a14c0..02cb864cb 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -1,5 +1,5 @@ { - "@metadata": { + "@metadata": { "authors": [ "Ajeje Brazorf", "Amire80", @@ -402,7 +402,6 @@ "PROCEDURES_IFRETURN_HELPURL": "{{Optional}} url - Information about guard clauses.", "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.", "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}}", @@ -464,6 +463,10 @@ "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.", "KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.", "KEYBOARD_NAV_CUT_HINT": "Message shown when an item is cut in keyboard navigation mode.", + "WORKSPACE_LABEL_1_STACK": "Aria label for a workspace with one stack of blocks.", + "WORKSPACE_LABEL_MANY_STACKS": "Aria label for a workspace with 0 or >1 stacks of blocks. \n\nParameters:\n* %1 - the number of stacks of blocks. A stack of blocks is a group of connected blocks that are not connected to any other blocks. 0 stacks means there are no blocks on the workspace.", + "WORKSPACE_LABEL_MUTATOR_WORKSPACE": "Aria label for a mutator workspace, which is a secondary workspace used for editing a block's structure. This type of workspace appears when a user clicks on the gear icon of a block that has a mutator, and allows the user to add, remove, or rearrange inputs to that block.", + "WORKSPACE_LABEL_FLYOUT_WORKSPACE": "Aria label for an always-open flyout's workspace. Since the flyout will have a role of list, the resulting screenreader output will be something like 'Logic blocks list, with 5 items'. Do not include the word 'list' in this message. Parameters: %1 - the category of blocks in the flyout, e.g. 'Logic' or 'Math'. This may be empty for an uncategorized flyout.", "WORKSPACE_CONTENTS_BLOCKS_MANY": "ARIA live region message announcing the number of stacks of blocks in the workspace, optionally including comments. \n\nParameters:\n* %1 - the number of stacks (integer greater than 1)\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* '5 stacks of blocks in workspace.'\n* '5 stacks of blocks and 2 comments in workspace.'", "WORKSPACE_CONTENTS_BLOCKS_ONE": "ARIA live region message announcing there is one stack of blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'One stack of blocks in workspace.'\n* 'One stack of blocks and 1 comment in workspace.'", "WORKSPACE_CONTENTS_BLOCKS_ZERO": "ARIA live region message announcing there are no blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'No blocks in workspace.'\n* 'No blocks and 3 comments in workspace.'", @@ -502,5 +505,7 @@ "FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'", "FIELD_LABEL_CHECKBOX_CHECKED": "Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field.", "FIELD_LABEL_CHECKBOX_UNCHECKED": "Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field.", - "FIELD_LABEL_VARIABLE": "Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. \n\nParameters:\n* %1 - the name of the variable represented by the option \n\nExamples:\n* 'Variable 'item''\n* 'Variable 'x''" + "FIELD_LABEL_VARIABLE": "Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. \n\nParameters:\n* %1 - the name of the variable represented by the option \n\nExamples:\n* 'Variable 'item''\n* 'Variable 'x''", + "ARIA_LABEL_BUTTON": "Part of an aria label for an element that indicates it is a button, but for technical reasons cannot be give a role of button. Ideally, this would match the localized name for what screenreaders announce for