From 3a53af903c2c3b662fe00094cc47294f6241ce70 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Fri, 16 May 2025 10:58:56 -0700 Subject: [PATCH 01/32] fix: Ensure `FieldImage` is clickable when appropriate (#9063) * fix: Ensure FieldImage is clickable when valid. * chore: Add new tests for FieldImage.isClickable(). --- core/field_image.ts | 11 ++++ tests/mocha/field_image_test.js | 110 ++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/core/field_image.ts b/core/field_image.ts index 6dfe2530e50..01133c20340 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -212,6 +212,17 @@ export class FieldImage extends Field { } } + /** + * Check whether this field should be clickable. + * + * @returns Whether this field is clickable. + */ + isClickable(): boolean { + // Images are only clickable if they have a click handler and fulfill the + // contract to be clickable: enabled and attached to an editable block. + return super.isClickable() && !!this.clickHandler; + } + /** * If field click is called, and click handler defined, * call the handler. diff --git a/tests/mocha/field_image_test.js b/tests/mocha/field_image_test.js index 89dd5fcc91b..a02b3f6b64a 100644 --- a/tests/mocha/field_image_test.js +++ b/tests/mocha/field_image_test.js @@ -20,6 +20,7 @@ import { suite('Image Fields', function () { setup(function () { sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv'); }); teardown(function () { sharedTestTeardown.call(this); @@ -237,5 +238,114 @@ suite('Image Fields', function () { assert.isTrue(field.getFlipRtl()); }); }); + suite('isClickable', function () { + setup(function () { + this.onClick = function () { + console.log('on click'); + }; + this.setUpBlockWithFieldImages = function () { + const blockJson = { + 'type': 'text', + 'id': 'block_id', + 'x': 0, + 'y': 0, + 'fields': { + 'TEXT': '', + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + return this.workspace.getBlockById('block_id'); + }; + this.extractFieldImage = function (block) { + const fields = Array.from(block.getFields()); + // Sanity check (as a precondition). + assert.strictEqual(fields.length, 3); + const imageField = fields[0]; + // Sanity check (as a precondition). + assert.isTrue(imageField instanceof Blockly.FieldImage); + return imageField; + }; + }); + + test('Unattached field without click handler returns false', function () { + const field = new Blockly.FieldImage('src', 10, 10, null); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('Unattached field with click handler returns false', function () { + const field = new Blockly.FieldImage('src', 10, 10, this.onClick); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached but disabled field without click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + field.setEnabled(false); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached but disabled field with click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + field.setEnabled(false); + field.setOnClickHandler(this.onClick); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached, enabled, but not editable field without click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + block.setEditable(false); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached, enabled, but not editable field with click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + block.setEditable(false); + field.setOnClickHandler(this.onClick); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached, enabled, editable field without click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached, enabled, editable field with click handler returns true', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + field.setOnClickHandler(this.onClick); + + const isClickable = field.isClickable(); + + assert.isTrue(isClickable); + }); + test('For attached, enabled, editable field with removed click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + field.setOnClickHandler(this.onClick); + field.setOnClickHandler(null); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + }); }); }); From 64160d136f742693f39aefc804bb577f51b01865 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 08:11:33 -0700 Subject: [PATCH 02/32] chore(deps): bump @blockly/dev-tools from 8.0.12 to 9.0.0 (#9065) Bumps [@blockly/dev-tools](https://github.com/google/blockly-samples/tree/HEAD/plugins/dev-tools) from 8.0.12 to 9.0.0. - [Release notes](https://github.com/google/blockly-samples/releases) - [Changelog](https://github.com/google/blockly-samples/blob/master/plugins/dev-tools/CHANGELOG.md) - [Commits](https://github.com/google/blockly-samples/commits/@blockly/dev-tools@9.0.0/plugins/dev-tools) --- updated-dependencies: - dependency-name: "@blockly/dev-tools" dependency-version: 9.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 70 +++++++++++++++++++++++++++++------------------ package.json | 2 +- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index 684f30e0681..de013dc7278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@blockly/block-test": "^6.0.4", - "@blockly/dev-tools": "^8.0.6", + "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", "@hyperjump/json-schema": "^1.5.0", @@ -101,16 +101,17 @@ } }, "node_modules/@blockly/dev-tools": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-8.0.12.tgz", - "integrity": "sha512-jE0y/Z7ggmM2JS4l0Xf2ic3eecuM+ZDjUZNCcM2k6yy0VDJoxOPN63Cq2soswXQRuKHfzRMHY48rCvoKL3MqPA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.0.tgz", + "integrity": "sha512-c2JJbj5Q9mGdy0iUvE5OBOl1zmSMJrSokORgnmrhxGCiJ6QexPGCsi1QAn6uzpUtGKjhpnEAQ6+jX7ROZe7QQg==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@blockly/block-test": "^6.0.11", - "@blockly/theme-dark": "^7.0.10", - "@blockly/theme-deuteranopia": "^6.0.10", - "@blockly/theme-highcontrast": "^6.0.10", - "@blockly/theme-tritanopia": "^6.0.10", + "@blockly/block-test": "^7.0.0", + "@blockly/theme-dark": "^8.0.0", + "@blockly/theme-deuteranopia": "^7.0.0", + "@blockly/theme-highcontrast": "^7.0.0", + "@blockly/theme-tritanopia": "^7.0.0", "chai": "^4.2.0", "dat.gui": "^0.7.7", "lodash.assign": "^4.2.0", @@ -122,7 +123,20 @@ "node": ">=8.0.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" + } + }, + "node_modules/@blockly/dev-tools/node_modules/@blockly/block-test": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.0.tgz", + "integrity": "sha512-Y+Iwg1hHmOaqXveTOiZNXHH+jNBP+LC5L8ZxKKWeO8aB9DZD5G2hgApHfLaxeZzqnCl8zspvGnrrlFy9foEdWw==", + "dev": true, + "license": "Apache 2.0", + "engines": { + "node": ">=8.17.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" } }, "node_modules/@blockly/dev-tools/node_modules/assertion-error": { @@ -195,39 +209,42 @@ } }, "node_modules/@blockly/theme-dark": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-7.0.10.tgz", - "integrity": "sha512-Wc6n115vt9alxzPkEwYtvBBGoPUV3gaYE00dvSKhqXTNoy1Xioujj9kT9VkGmdMO2mhgnJNczSpvxG8tcd4zLQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-8.0.0.tgz", + "integrity": "sha512-Fq8ifjCwbJW305Su7SNBP8jXs4h1hp2EdQ9cMGOCr/racRIYfDRRBqjy0ZRLLqI7BsgZKxKy6Aa+OjgWEKeKfw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/theme-deuteranopia": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-6.0.10.tgz", - "integrity": "sha512-im5nIvf/Z0f1vJ9DK5Euu6URfY8G44xeFsat2b7TySF0BfAUWkGsagK3C6D5NatigPxKZqz3exC9zeXEtprAcg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-7.0.0.tgz", + "integrity": "sha512-zKhlnD/AF3MR9+Rlwus3vAPq8gwCZaZ08VEupvz5b98mk36suRlIrQanM8HVLGcozxiEvUNrTNOGO5kj8PeTWA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/theme-highcontrast": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-6.0.10.tgz", - "integrity": "sha512-s1hehl/b50IhebCs20hm2hFWbUTqJ2YSGdR0gnp2NLfNNRWwyZHZk+q4aG3k4L0YBWjNfE3XiRCkDISy83dBIA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-7.0.0.tgz", + "integrity": "sha512-6Apkw5iUlOq1DoOJgwsfo8Iha2OkxXMSNHqb8ZVVmUhCHjce0XMXgq1Rqty/2l/C2AKB+WWLZEWxOyGWYrQViQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/theme-modern": { @@ -243,15 +260,16 @@ } }, "node_modules/@blockly/theme-tritanopia": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-6.0.10.tgz", - "integrity": "sha512-QNIvUHokGMLnCWUzERRZa6sSkD5RIUynWDI+KNurBH21NeWnSNScQiNu0dS/w5MSkZ/Iqqbi79UZoF49SzEayg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-7.0.0.tgz", + "integrity": "sha512-22TFAuY8ilKsQomDC8GXMHsCfdR8l75yPPFl6AOCcok2FJLkiyhjGpAy2cNexA9P2xP/rW7vdsG3wC8ukWihUA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@csstools/color-helpers": { diff --git a/package.json b/package.json index 5407f21afd7..523b5590fad 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "license": "Apache-2.0", "devDependencies": { "@blockly/block-test": "^6.0.4", - "@blockly/dev-tools": "^8.0.6", + "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", "@hyperjump/json-schema": "^1.5.0", From 7d0414c5ddd31843ac12d59709f5964c46cc1512 Mon Sep 17 00:00:00 2001 From: RoboErikG Date: Mon, 19 May 2025 09:35:59 -0700 Subject: [PATCH 03/32] fix: When moving to a field, scroll the field's block into view (#9071) * fix: When moving to a field, scroll the field's block into view * fix formatting --- core/keyboard_nav/line_cursor.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 9d83f6554d3..8cc51f90306 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -14,6 +14,7 @@ */ import {BlockSvg} from '../block_svg.js'; +import {Field} from '../field.js'; import {getFocusManager} from '../focus_manager.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import {isFocusableNode} from '../interfaces/i_focusable_node.js'; @@ -406,6 +407,11 @@ export class LineCursor extends Marker { newNode.workspace.scrollBoundsIntoView( newNode.getBoundingRectangleWithoutChildren(), ); + } else if (newNode instanceof Field) { + const block = newNode.getSourceBlock() as BlockSvg; + block.workspace.scrollBoundsIntoView( + block.getBoundingRectangleWithoutChildren(), + ); } } From 3010ceee2c19960724497c759c0b457ecddbc1d9 Mon Sep 17 00:00:00 2001 From: RoboErikG Date: Mon, 19 May 2025 09:47:16 -0700 Subject: [PATCH 04/32] fix: Skip hidden fields when navigating (#9070) --- core/keyboard_nav/field_navigation_policy.ts | 1 + tests/mocha/navigation_test.js | 96 ++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/core/keyboard_nav/field_navigation_policy.ts b/core/keyboard_nav/field_navigation_policy.ts index 3b88dc9248b..a89a70859bc 100644 --- a/core/keyboard_nav/field_navigation_policy.ts +++ b/core/keyboard_nav/field_navigation_policy.ts @@ -97,6 +97,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { isNavigable(current: Field): boolean { return ( current.canBeFocused() && + current.isVisible() && (current.isClickable() || current.isCurrentlyEditable()) && !( current.getSourceBlock()?.isSimpleReporter() && diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index 0462d4daa0d..57a7d32e9d7 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -164,6 +164,30 @@ suite('Navigation', function () { 'tooltip': '', 'helpUrl': '', }, + { + 'type': 'hidden_field', + 'message0': '%1 %2 %3', + 'args0': [ + { + 'type': 'field_input', + 'name': 'ONE', + 'text': 'default', + }, + { + 'type': 'field_input', + 'name': 'TWO', + 'text': 'default', + }, + { + 'type': 'field_input', + 'name': 'THREE', + 'text': 'default', + }, + ], + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, { 'type': 'fields_and_input2', 'message0': '%1 %2 %3 %4 bye', @@ -222,17 +246,61 @@ suite('Navigation', function () { 'helpUrl': '', 'nextStatement': null, }, + { + 'type': 'hidden_input', + 'message0': '%1 hi %2 %3 %4 %5 %6', + 'args0': [ + { + 'type': 'field_input', + 'name': 'ONE', + 'text': 'default', + }, + { + 'type': 'input_dummy', + }, + { + 'type': 'field_input', + 'name': 'TWO', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'SECOND', + }, + { + 'type': 'field_input', + 'name': 'THREE', + 'text': 'default', + }, + { + 'type': 'input_value', + 'name': 'THIRD', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + 'colour': 230, + 'tooltip': '', + 'helpUrl': '', + }, ]); const noNextConnection = this.workspace.newBlock('top_connection'); const fieldAndInputs = this.workspace.newBlock('fields_and_input'); const twoFields = this.workspace.newBlock('two_fields'); const fieldAndInputs2 = this.workspace.newBlock('fields_and_input2'); const noPrevConnection = this.workspace.newBlock('start_block'); + const hiddenField = this.workspace.newBlock('hidden_field'); + const hiddenInput = this.workspace.newBlock('hidden_input'); this.blocks.noNextConnection = noNextConnection; this.blocks.fieldAndInputs = fieldAndInputs; this.blocks.twoFields = twoFields; this.blocks.fieldAndInputs2 = fieldAndInputs2; this.blocks.noPrevConnection = noPrevConnection; + this.blocks.hiddenField = hiddenField; + this.blocks.hiddenInput = hiddenInput; + + hiddenField.inputList[0].fieldRow[1].setVisible(false); + hiddenInput.inputList[1].setVisible(false); const dummyInput = this.workspace.newBlock('dummy_input'); const dummyInputValue = this.workspace.newBlock('dummy_inputValue'); @@ -322,6 +390,20 @@ suite('Navigation', function () { const nextNode = this.navigator.getNextSibling(field); assert.isNull(nextNode); }); + test('skipsHiddenField', function () { + const field = this.blocks.hiddenField.inputList[0].fieldRow[0]; + const field2 = this.blocks.hiddenField.inputList[0].fieldRow[2]; + const nextNode = this.navigator.getNextSibling(field); + assert.equal(nextNode.name, field2.name); + }); + test('skipsHiddenInput', function () { + const field = this.blocks.hiddenInput.inputList[0].fieldRow[0]; + const nextNode = this.navigator.getNextSibling(field); + assert.equal( + nextNode, + this.blocks.hiddenInput.inputList[2].fieldRow[0], + ); + }); }); suite('Previous', function () { @@ -400,6 +482,20 @@ suite('Navigation', function () { const prevNode = this.navigator.getPreviousSibling(field); assert.equal(prevNode, field2); }); + test('skipsHiddenField', function () { + const field = this.blocks.hiddenField.inputList[0].fieldRow[2]; + const field2 = this.blocks.hiddenField.inputList[0].fieldRow[0]; + const prevNode = this.navigator.getPreviousSibling(field); + assert.equal(prevNode.name, field2.name); + }); + test('skipsHiddenInput', function () { + const field = this.blocks.hiddenInput.inputList[2].fieldRow[0]; + const nextNode = this.navigator.getPreviousSibling(field); + assert.equal( + nextNode, + this.blocks.hiddenInput.inputList[0].fieldRow[0], + ); + }); }); suite('In', function () { From 91632a4861e223cdc01c884d05cd6c273d2bd3a2 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 19 May 2025 10:16:38 -0700 Subject: [PATCH 05/32] fix: Limit LineCursor<-focus syncing. (#9062) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes https://github.com/google/blockly-keyboard-experimentation/issues/526 ### Proposed Changes This limits synchronizing in `LineCursor` from `FocusManager` to just nodes that have a corresponding block. ### Reason for Changes Limiting the synchronizing in this way ensures that navigation can't enter a bad state. The reason for why this is needed is explained in https://github.com/google/blockly-keyboard-experimentation/issues/526#issuecomment-2885117998. Longer term it would maybe be ideal to do one or both of the following: - Figure out ways of making navigation a bit more robust (perhaps on the keyboard navigation side) such that if cursor _is_ in a bad state there's some way to recover (rather than ending up permanently broken). - Remove `Marker`'s internal state in favor of always relying on `FocusManager`'s state to cover the cases where there can be automatic focus shifting. ### Test Coverage This was manually tested with the keyboard navigation plugin and verified to ensure that both https://github.com/google/blockly-keyboard-experimentation/issues/526 and https://github.com/google/blockly-keyboard-experimentation/issues/499 are (still) working as expected. Some basic testing was done with the core simple playground with the developer console open to ensure there weren't any expected failures. Automated testing cases would be better addressed as part of resolving #8915. ### Documentation No new documentation is needed here. ### Additional Information This behavior is expected to only affect the keyboard navigation plugin. --- core/keyboard_nav/line_cursor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 8cc51f90306..85c0f414a07 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -378,7 +378,7 @@ export class LineCursor extends Marker { // Ensure the current node matches what's currently focused. const focused = getFocusManager().getFocusedNode(); const block = this.getSourceBlockFromNode(focused); - if (!block || block.workspace === this.workspace) { + if (block && block.workspace === this.workspace) { // If the current focused node corresponds to a block then ensure that it // belongs to the correct workspace for this cursor. this.setCurNode(focused); From 8e1133753147f64b0469c36f9d2c2e77d68a5341 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 11:47:20 -0700 Subject: [PATCH 06/32] chore(deps): bump glob from 11.0.1 to 11.0.2 (#9066) Bumps [glob](https://github.com/isaacs/node-glob) from 11.0.1 to 11.0.2. - [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md) - [Commits](https://github.com/isaacs/node-glob/compare/v11.0.1...v11.0.2) --- updated-dependencies: - dependency-name: glob dependency-version: 11.0.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index de013dc7278..6bb99305864 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5323,9 +5323,9 @@ } }, "node_modules/glob": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", - "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", + "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", "dev": true, "license": "ISC", "dependencies": { From 361b453f17a612f00bce295d9a119270f51a52ee Mon Sep 17 00:00:00 2001 From: RoboErikG Date: Mon, 19 May 2025 14:25:55 -0700 Subject: [PATCH 07/32] fix: Fix browser tests PART 1 (#9064) * Move block into view before clicking * fix right click test failures * Fix drag three blocks test dragAndDrop is relative to the start and the test window is very small. * Fix a few more tests - Switch to using clickBlock instead of getting the block and clicking it - Update drag positions for some tests so they don't snap and change size * Add a pause between right clicking a block and waiting for the menu * Fix mutator test by finding the dragged out elseif block * Make disable test less flakey --- tests/browser/test/basic_block_test.mjs | 2 +- tests/browser/test/basic_playground_test.mjs | 27 ++++++------ tests/browser/test/delete_blocks_test.mjs | 13 +++--- tests/browser/test/extensive_test.mjs | 11 ++--- tests/browser/test/mutator_test.mjs | 9 ++-- tests/browser/test/test_setup.mjs | 45 ++++++++++++++------ 6 files changed, 63 insertions(+), 44 deletions(-) diff --git a/tests/browser/test/basic_block_test.mjs b/tests/browser/test/basic_block_test.mjs index 326e20ecfd4..52912f0cd86 100644 --- a/tests/browser/test/basic_block_test.mjs +++ b/tests/browser/test/basic_block_test.mjs @@ -31,7 +31,7 @@ suite('Basic block tests', function (done) { test('Drag three blocks into the workspace', async function () { for (let i = 1; i <= 3; i++) { - await dragNthBlockFromFlyout(this.browser, 'Align', 0, 250, 50 * i); + await dragNthBlockFromFlyout(this.browser, 'Align', 0, 50, 50); chai.assert.equal((await getAllBlocks(this.browser)).length, i); } }); diff --git a/tests/browser/test/basic_playground_test.mjs b/tests/browser/test/basic_playground_test.mjs index c0a1f893037..4c54523bd7f 100644 --- a/tests/browser/test/basic_playground_test.mjs +++ b/tests/browser/test/basic_playground_test.mjs @@ -126,15 +126,15 @@ suite('Disabling', function () { this.browser, 'Logic', 'controls_if', - 10, - 10, + 15, + 0, ); const child = await dragBlockTypeFromFlyout( this.browser, 'Logic', 'logic_boolean', - 110, - 110, + 100, + 0, ); await connect(this.browser, child, 'OUTPUT', parent, 'IF0'); await this.browser.pause(PAUSE_TIME); @@ -152,18 +152,20 @@ suite('Disabling', function () { this.browser, 'Logic', 'controls_if', - 10, - 10, + 15, + 0, ); const child = await dragBlockTypeFromFlyout( this.browser, 'Logic', 'controls_if', - 110, - 110, + 100, + 0, ); + await this.browser.pause(PAUSE_TIME); await connect(this.browser, child, 'PREVIOUS', parent, 'DO0'); + await this.browser.pause(PAUSE_TIME); await contextMenuSelect(this.browser, parent, 'Disable Block'); chai.assert.isTrue(await getIsDisabled(this.browser, child.id)); @@ -178,16 +180,17 @@ suite('Disabling', function () { this.browser, 'Logic', 'controls_if', - 10, - 10, + 15, + 0, ); const child = await dragBlockTypeFromFlyout( this.browser, 'Logic', 'controls_if', - 110, - 110, + 100, + 0, ); + await connect(this.browser, child, 'PREVIOUS', parent, 'NEXT'); await contextMenuSelect(this.browser, parent, 'Disable Block'); diff --git a/tests/browser/test/delete_blocks_test.mjs b/tests/browser/test/delete_blocks_test.mjs index a5df88705c5..a407ad0600f 100644 --- a/tests/browser/test/delete_blocks_test.mjs +++ b/tests/browser/test/delete_blocks_test.mjs @@ -141,7 +141,7 @@ suite('Delete blocks', function (done) { test('Delete block using backspace key', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. - await clickBlock(this.browser, this.firstBlock, {button: 1}); + await clickBlock(this.browser, this.firstBlock.id, {button: 1}); await this.browser.keys([Key.Backspace]); const after = (await getAllBlocks(this.browser)).length; chai.assert.equal( @@ -154,7 +154,7 @@ suite('Delete blocks', function (done) { test('Delete block using delete key', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using delete key. - await clickBlock(this.browser, this.firstBlock, {button: 1}); + await clickBlock(this.browser, this.firstBlock.id, {button: 1}); await this.browser.keys([Key.Delete]); const after = (await getAllBlocks(this.browser)).length; chai.assert.equal( @@ -176,10 +176,11 @@ suite('Delete blocks', function (done) { ); }); - test('Undo block deletion', async function () { + // TODO(#9029) enable this test once deleting a block doesn't lose focus + test.skip('Undo block deletion', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. - await clickBlock(this.browser, this.firstBlock, {button: 1}); + await clickBlock(this.browser, this.firstBlock.id, {button: 1}); await this.browser.keys([Key.Backspace]); await this.browser.pause(PAUSE_TIME); // Undo @@ -187,8 +188,8 @@ suite('Delete blocks', function (done) { await this.browser.pause(PAUSE_TIME); const after = (await getAllBlocks(this.browser)).length; chai.assert.equal( - before, after, + before, 'Expected there to be the original number of blocks after undoing a delete', ); }); @@ -196,7 +197,7 @@ suite('Delete blocks', function (done) { test('Redo block deletion', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. - await clickBlock(this.browser, this.firstBlock, {button: 1}); + await clickBlock(this.browser, this.firstBlock.id, {button: 1}); await this.browser.keys([Key.Backspace]); await this.browser.pause(PAUSE_TIME); // Undo diff --git a/tests/browser/test/extensive_test.mjs b/tests/browser/test/extensive_test.mjs index 786be0ade53..bef8bc9345d 100644 --- a/tests/browser/test/extensive_test.mjs +++ b/tests/browser/test/extensive_test.mjs @@ -11,8 +11,8 @@ import * as chai from 'chai'; import {Key} from 'webdriverio'; import { + clickBlock, getAllBlocks, - getBlockElementById, PAUSE_TIME, testFileLocations, testSetup, @@ -33,18 +33,15 @@ suite('This tests loading Large Configuration and Deletion', function (done) { }); test('deleting block results in the correct number of blocks', async function () { - const fourthRepeatDo = await getBlockElementById( - this.browser, - 'E8bF[-r:B~cabGLP#QYd', - ); - await fourthRepeatDo.click({x: -100, y: -40}); + await clickBlock(this.browser, 'E8bF[-r:B~cabGLP#QYd', {button: 1}); await this.browser.keys([Key.Delete]); await this.browser.pause(PAUSE_TIME); const allBlocks = await getAllBlocks(this.browser); chai.assert.equal(allBlocks.length, 10); }); - test('undoing delete block results in the correct number of blocks', async function () { + // TODO(#8793) Re-enable test after deleting a block updates focus correctly. + test.skip('undoing delete block results in the correct number of blocks', async function () { await this.browser.keys([Key.Ctrl, 'z']); await this.browser.pause(PAUSE_TIME); const allBlocks = await getAllBlocks(this.browser); diff --git a/tests/browser/test/mutator_test.mjs b/tests/browser/test/mutator_test.mjs index 6d077b9fd92..b12ae5698c9 100644 --- a/tests/browser/test/mutator_test.mjs +++ b/tests/browser/test/mutator_test.mjs @@ -34,16 +34,15 @@ async function testMutator(browser, delta) { browser, 'Logic', 'controls_if', - delta * 50, + delta * 150, 50, ); await openMutatorForBlock(browser, mutatorBlock); - await browser.pause(PAUSE_TIME); await dragBlockFromMutatorFlyout( browser, mutatorBlock, 'controls_if_elseif', - delta * 50, + delta * 150, 50, ); await browser.pause(PAUSE_TIME); @@ -67,8 +66,8 @@ async function testMutator(browser, delta) { 'g:nth-child(2) > svg:nth-child(1) > g > g.blocklyBlockCanvas > ' + 'g.blocklyDraggable', ); - // For some reason this needs a lot more time. - await browser.pause(2000); + + await browser.pause(PAUSE_TIME); await connect( browser, await getBlockElementById(browser, elseIfQuarkId), diff --git a/tests/browser/test/test_setup.mjs b/tests/browser/test/test_setup.mjs index 9b48a3638ae..04a192a46a7 100644 --- a/tests/browser/test/test_setup.mjs +++ b/tests/browser/test/test_setup.mjs @@ -165,28 +165,35 @@ export async function getBlockElementById(browser, id) { * causes problems if it has holes (e.g. statement inputs). Instead, this tries * to get the first text field on the block. It falls back on the block's SVG root. * @param browser The active WebdriverIO Browser object. - * @param block The block to click, as an interactable element. + * @param blockId The id of the block to click, as an interactable element. * @param clickOptions The options to pass to webdriverio's element.click function. * @return A Promise that resolves when the actions are completed. */ -export async function clickBlock(browser, block, clickOptions) { +export async function clickBlock(browser, blockId, clickOptions) { const findableId = 'clickTargetElement'; // In the browser context, find the element that we want and give it a findable ID. await browser.execute( (blockId, newElemId) => { const block = Blockly.getMainWorkspace().getBlockById(blockId); - for (const input of block.inputList) { - for (const field of input.fieldRow) { - if (field instanceof Blockly.FieldLabel) { - field.getSvgRoot().id = newElemId; - return; + // Ensure the block we want to click is within the viewport. + Blockly.getMainWorkspace().scrollBoundsIntoView( + block.getBoundingRectangleWithoutChildren(), + 10, + ); + if (!block.isCollapsed()) { + for (const input of block.inputList) { + for (const field of input.fieldRow) { + if (field instanceof Blockly.FieldLabel) { + field.getSvgRoot().id = newElemId; + return; + } } } } // No label field found. Fall back to the block's SVG root. - block.getSvgRoot().id = findableId; + block.getSvgRoot().id = newElemId; }, - block.id, + blockId, findableId, ); @@ -477,8 +484,8 @@ export async function dragBlockTypeFromFlyout( } /** - * Drags the specified block type from the mutator flyout of the given block and - * returns the root element of the block. + * Drags the specified block type from the mutator flyout of the given block + * and returns the root element of the block. * * @param browser The active WebdriverIO Browser object. * @param mutatorBlock The block with the mutator attached that we want to drag @@ -512,7 +519,18 @@ export async function dragBlockFromMutatorFlyout( ); const flyoutBlock = await getBlockElementById(browser, id); await flyoutBlock.dragAndDrop({x: x, y: y}); - return await getSelectedBlockElement(browser); + + const draggedBlockId = await browser.execute( + (mutatorBlockId, blockType) => { + return Blockly.getMainWorkspace() + .getBlockById(mutatorBlockId) + .mutator.getWorkspace() + .getBlocksByType(blockType)[0].id; + }, + mutatorBlock.id, + type, + ); + return await getBlockElementById(browser, draggedBlockId); } /** @@ -526,8 +544,9 @@ export async function dragBlockFromMutatorFlyout( * @return A Promise that resolves when the actions are completed. */ export async function contextMenuSelect(browser, block, itemText) { - await clickBlock(browser, block, {button: 2}); + await clickBlock(browser, block.id, {button: 2}); + await browser.pause(PAUSE_TIME); const item = await browser.$(`div=${itemText}`); await item.waitForExist(); await item.click(); From 135da402ef0035ce5a44d4a5d5d73d576e92bc82 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Mon, 19 May 2025 17:18:38 -0700 Subject: [PATCH 08/32] fix: focus something after deleting a block (#9073) --- core/block_svg.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/block_svg.ts b/core/block_svg.ts index ea5dd7da7ed..f8e37fcc9d8 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -849,6 +849,17 @@ export class BlockSvg Tooltip.dispose(); ContextMenu.hide(); + // If this block was focused, focus its parent or workspace instead. + const focusManager = getFocusManager(); + if (focusManager.getFocusedNode() === this) { + const parent = this.getParent(); + if (parent) { + focusManager.focusNode(parent); + } else { + focusManager.focusTree(this.workspace); + } + } + if (animate) { this.unplug(healStack); blockAnimations.disposeUiEffect(this); From 53d78765391f9917373b0239a854c35b5d67de5c Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 20 May 2025 08:52:18 -0700 Subject: [PATCH 09/32] feat: Add keyboard navigation support for icons. (#9072) * feat: Add keyboard navigation support for icons. * chore: Satisfy the linter. --- core/icons/icon.ts | 9 ++ core/keyboard_nav/block_navigation_policy.ts | 3 + core/keyboard_nav/field_navigation_policy.ts | 3 +- core/keyboard_nav/icon_navigation_policy.ts | 96 ++++++++++++++++++++ core/navigator.ts | 2 + tests/mocha/navigation_test.js | 63 +++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 core/keyboard_nav/icon_navigation_policy.ts diff --git a/core/icons/icon.ts b/core/icons/icon.ts index 5bf61d49c91..67547ee313e 100644 --- a/core/icons/icon.ts +++ b/core/icons/icon.ts @@ -178,4 +178,13 @@ export abstract class Icon implements IIcon { canBeFocused(): boolean { return true; } + + /** + * Returns the block that this icon is attached to. + * + * @returns The block this icon is attached to. + */ + getSourceBlock(): Block { + return this.sourceBlock; + } } diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 74f970d9961..d0f6d230b19 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -21,6 +21,9 @@ export class BlockNavigationPolicy implements INavigationPolicy { * @returns The first field or input of the given block, if any. */ getFirstChild(current: BlockSvg): IFocusableNode | null { + const icons = current.getIcons(); + if (icons.length) return icons[0]; + for (const input of current.inputList) { for (const field of input.fieldRow) { return field; diff --git a/core/keyboard_nav/field_navigation_policy.ts b/core/keyboard_nav/field_navigation_policy.ts index a89a70859bc..02d31cc355f 100644 --- a/core/keyboard_nav/field_navigation_policy.ts +++ b/core/keyboard_nav/field_navigation_policy.ts @@ -85,7 +85,8 @@ export class FieldNavigationPolicy implements INavigationPolicy> { fieldIdx = block.inputList[i - 1].fieldRow.length - 1; } } - return null; + + return block.getIcons().pop() ?? null; } /** diff --git a/core/keyboard_nav/icon_navigation_policy.ts b/core/keyboard_nav/icon_navigation_policy.ts new file mode 100644 index 00000000000..19d5a9c4d13 --- /dev/null +++ b/core/keyboard_nav/icon_navigation_policy.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../block_svg.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'; + +/** + * Set of rules controlling keyboard navigation from an icon. + */ +export class IconNavigationPolicy implements INavigationPolicy { + /** + * Returns the first child of the given icon. + * + * @param _current The icon to return the first child of. + * @returns Null. + */ + getFirstChild(_current: Icon): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given icon. + * + * @param current The icon to return the parent of. + * @returns The source block of the given icon. + */ + getParent(current: Icon): IFocusableNode | null { + return current.getSourceBlock() as BlockSvg; + } + + /** + * Returns the next peer node of the given icon. + * + * @param current The icon to find the following element of. + * @returns The next icon, field or input following this icon, if any. + */ + getNextSibling(current: Icon): IFocusableNode | null { + const block = current.getSourceBlock() as BlockSvg; + const icons = block.getIcons(); + const currentIndex = icons.indexOf(current); + if (currentIndex >= 0 && currentIndex + 1 < icons.length) { + return icons[currentIndex + 1]; + } + + for (const input of block.inputList) { + if (input.fieldRow.length) return input.fieldRow[0]; + + if (input.connection?.targetBlock()) + return input.connection.targetBlock() as BlockSvg; + } + + return null; + } + + /** + * Returns the previous peer node of the given icon. + * + * @param current The icon to find the preceding element of. + * @returns The icon's previous icon, if any. + */ + getPreviousSibling(current: Icon): IFocusableNode | null { + const block = current.getSourceBlock() as BlockSvg; + const icons = block.getIcons(); + const currentIndex = icons.indexOf(current); + if (currentIndex >= 1) { + return icons[currentIndex - 1]; + } + + return null; + } + + /** + * Returns whether or not the given icon can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given icon can be focused. + */ + isNavigable(current: Icon): 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 Icon. + */ + isApplicable(current: any): current is Icon { + return current instanceof Icon; + } +} diff --git a/core/navigator.ts b/core/navigator.ts index 7a1c2d4ea10..92c921122dc 100644 --- a/core/navigator.ts +++ b/core/navigator.ts @@ -9,6 +9,7 @@ import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; import {BlockNavigationPolicy} from './keyboard_nav/block_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 {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js'; type RuleList = INavigationPolicy[]; @@ -27,6 +28,7 @@ export class Navigator { new FieldNavigationPolicy(), new ConnectionNavigationPolicy(), new WorkspaceNavigationPolicy(), + new IconNavigationPolicy(), ]; /** diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index 57a7d32e9d7..3675c08d602 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -404,6 +404,29 @@ suite('Navigation', function () { this.blocks.hiddenInput.inputList[2].fieldRow[0], ); }); + test('from icon to icon', function () { + this.blocks.statementInput1.setCommentText('test'); + this.blocks.statementInput1.setWarningText('test'); + const icons = this.blocks.statementInput1.getIcons(); + const nextNode = this.navigator.getNextSibling(icons[0]); + assert.equal(nextNode, icons[1]); + }); + test('from icon to field', function () { + this.blocks.statementInput1.setCommentText('test'); + this.blocks.statementInput1.setWarningText('test'); + const icons = this.blocks.statementInput1.getIcons(); + const nextNode = this.navigator.getNextSibling(icons[1]); + assert.equal( + nextNode, + this.blocks.statementInput1.inputList[0].fieldRow[0], + ); + }); + test('from icon to null', function () { + this.blocks.dummyInput.setCommentText('test'); + const icons = this.blocks.dummyInput.getIcons(); + const nextNode = this.navigator.getNextSibling(icons[0]); + assert.isNull(nextNode); + }); }); suite('Previous', function () { @@ -496,6 +519,28 @@ suite('Navigation', function () { this.blocks.hiddenInput.inputList[0].fieldRow[0], ); }); + test('from icon to icon', function () { + this.blocks.statementInput1.setCommentText('test'); + this.blocks.statementInput1.setWarningText('test'); + const icons = this.blocks.statementInput1.getIcons(); + const prevNode = this.navigator.getPreviousSibling(icons[1]); + assert.equal(prevNode, icons[0]); + }); + test('from field to icon', function () { + this.blocks.statementInput1.setCommentText('test'); + this.blocks.statementInput1.setWarningText('test'); + const icons = this.blocks.statementInput1.getIcons(); + const prevNode = this.navigator.getPreviousSibling( + this.blocks.statementInput1.inputList[0].fieldRow[0], + ); + assert.equal(prevNode, icons[1]); + }); + test('from icon to null', function () { + this.blocks.dummyInput.setCommentText('test'); + const icons = this.blocks.dummyInput.getIcons(); + const prevNode = this.navigator.getPreviousSibling(icons[0]); + assert.isNull(prevNode); + }); }); suite('In', function () { @@ -564,6 +609,18 @@ suite('Navigation', function () { const inNode = this.navigator.getFirstChild(this.emptyWorkspace); assert.isNull(inNode); }); + test('from block to icon', function () { + this.blocks.dummyInput.setCommentText('test'); + const icons = this.blocks.dummyInput.getIcons(); + const inNode = this.navigator.getFirstChild(this.blocks.dummyInput); + assert.equal(inNode, icons[0]); + }); + test('from icon to null', function () { + this.blocks.dummyInput.setCommentText('test'); + const icons = this.blocks.dummyInput.getIcons(); + const inNode = this.navigator.getFirstChild(icons[0]); + assert.isNull(inNode); + }); }); suite('Out', function () { @@ -661,6 +718,12 @@ suite('Navigation', function () { const outNode = this.navigator.getParent(this.blocks.outputNextBlock); assert.equal(outNode, inputConnection); }); + test('from icon to block', function () { + this.blocks.dummyInput.setCommentText('test'); + const icons = this.blocks.dummyInput.getIcons(); + const outNode = this.navigator.getParent(icons[0]); + assert.equal(outNode, this.blocks.dummyInput); + }); }); }); }); From 4f01c9937aa0d66891b6bdfc2cf3ef3e6f3fdcd6 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 20 May 2025 11:58:05 -0700 Subject: [PATCH 10/32] fix: focus after drag and deleting comments (#9074) * fix: focus after drag and deleting comments * chore: fix import --- core/block_svg.ts | 2 +- core/comments/rendered_workspace_comment.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index f8e37fcc9d8..8ea26e354ef 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -856,7 +856,7 @@ export class BlockSvg if (parent) { focusManager.focusNode(parent); } else { - focusManager.focusTree(this.workspace); + setTimeout(() => focusManager.focusTree(this.workspace), 0); } } diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index 76eeb64f16a..00359b07011 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -19,6 +19,7 @@ import {IContextMenu} from '../interfaces/i_contextmenu.js'; import {ICopyable} from '../interfaces/i_copyable.js'; import {IDeletable} from '../interfaces/i_deletable.js'; import {IDraggable} from '../interfaces/i_draggable.js'; +import {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import {ISelectable} from '../interfaces/i_selectable.js'; @@ -42,7 +43,8 @@ export class RenderedWorkspaceComment ISelectable, IDeletable, ICopyable, - IContextMenu + IContextMenu, + IFocusableNode { /** The class encompassing the svg elements making up the workspace comment. */ private view: CommentView; @@ -207,7 +209,12 @@ export class RenderedWorkspaceComment /** Disposes of the view. */ override dispose() { this.disposing = true; + const focusManager = getFocusManager(); + if (focusManager.getFocusedNode() === this) { + setTimeout(() => focusManager.focusTree(this.workspace), 0); + } if (!this.view.isDeadOrDying()) this.view.dispose(); + super.dispose(); } From 358371c8b9cebce130b88ef21fef11c86300039d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 15:55:54 +0100 Subject: [PATCH 11/32] chore(deps): bump webdriverio from 9.12.5 to 9.14.0 (#9068) Bumps [webdriverio](https://github.com/webdriverio/webdriverio/tree/HEAD/packages/webdriverio) from 9.12.5 to 9.14.0. - [Release notes](https://github.com/webdriverio/webdriverio/releases) - [Changelog](https://github.com/webdriverio/webdriverio/blob/main/CHANGELOG.md) - [Commits](https://github.com/webdriverio/webdriverio/commits/v9.14.0/packages/webdriverio) --- updated-dependencies: - dependency-name: webdriverio dependency-version: 9.14.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 85 ++++++++++++++++++++++++----------------------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6bb99305864..ed5aa2b3f65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1240,9 +1240,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.0.tgz", - "integrity": "sha512-HdHF4rny4JCvIcm7V1dpvpctIGqM3/Me255CB44vW7hDG1zYMmcBMjpNqZEDxdCfXGLkx5kP0+Jz5DUS+ukqtA==", + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.4.tgz", + "integrity": "sha512-9DxbZx+XGMNdjBynIs4BRSz+M3iRDeB7qRcAr6UORFLphCIM2x3DXgOucvADiifcqCE4XePFUKcnaAMyGbrDlQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1262,9 +1262,9 @@ } }, "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -1773,15 +1773,15 @@ } }, "node_modules/@wdio/config": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.12.5.tgz", - "integrity": "sha512-T4pOgY7FLj0+SBc58n81JZidCJKfqaSb9Ql9lOd38tmorEwTKjcPAzQQY1Ftzqv49kjBHvXdlupy685VVKNepA==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.14.0.tgz", + "integrity": "sha512-mW6VAXfUgd2j+8YJfFWvg8Ba/7g1Brr6/+MFBpp5rTQsw/2bN3PBJsQbWpNl99OCgoS8vgc5Ykps5ZUEeffSVQ==", "dev": true, "license": "MIT", "dependencies": { "@wdio/logger": "9.4.4", - "@wdio/types": "9.12.3", - "@wdio/utils": "9.12.5", + "@wdio/types": "9.14.0", + "@wdio/utils": "9.14.0", "deepmerge-ts": "^7.0.3", "glob": "^10.2.2", "import-meta-resolve": "^4.0.0" @@ -1923,9 +1923,9 @@ } }, "node_modules/@wdio/protocols": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.12.5.tgz", - "integrity": "sha512-i+yc0EZtZOh5fFuwHxvcnXeTXk2ZjFICRbcAxTNE0F2Jr4uOydvcAOw4EIIRmb9NWUSPf/bGZAA+4SEXmxmjUA==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.14.0.tgz", + "integrity": "sha512-inJR+G8iiFrk8/JPMfxpy6wA7rvMIZFV0T8vDN1Io7sGGj+EXX7ujpDxoCns53qxV4RytnSlgHRcCaASPFcecQ==", "dev": true, "license": "MIT" }, @@ -1943,9 +1943,9 @@ } }, "node_modules/@wdio/types": { - "version": "9.12.3", - "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.12.3.tgz", - "integrity": "sha512-MlnQ3WG1CQAjmUmeKtv3timGR91hSsCwQW9T1kqpu0VaJ/qbw3sWgtArMqRvgWB2H6IGueqQwDQ9qHlP013w9Q==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.14.0.tgz", + "integrity": "sha512-Zqc4sxaQLIXdI1EHItIuVIOn7LvPmDvl9JEANwiJ35ck82Xlj+X55Gd9NtELSwChzKgODD0OBzlLgXyxTr69KA==", "dev": true, "license": "MIT", "dependencies": { @@ -1956,15 +1956,15 @@ } }, "node_modules/@wdio/utils": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.12.5.tgz", - "integrity": "sha512-yddJj7VyA3kGuAuDU63ZdRBK4D1jwSU+52KwlZtOeqDdT/i6KAwRVYNYMwwmsGuM4GpY3q5h944YylBQNkKkjQ==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.14.0.tgz", + "integrity": "sha512-oJapwraSflOe0CmeF3TBocdt983hq9mCutLCfie4QmE+TKRlCsZz4iidG1NRAZPGdKB32nfHtyQlW0Dfxwn6RA==", "dev": true, "license": "MIT", "dependencies": { "@puppeteer/browsers": "^2.2.0", "@wdio/logger": "9.4.4", - "@wdio/types": "9.12.3", + "@wdio/types": "9.14.0", "decamelize": "^6.0.0", "deepmerge-ts": "^7.0.3", "edgedriver": "^6.1.1", @@ -1987,9 +1987,9 @@ "dev": true }, "node_modules/@zip.js/zip.js": { - "version": "2.7.60", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.60.tgz", - "integrity": "sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA==", + "version": "2.7.61", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.61.tgz", + "integrity": "sha512-+tZvY10nkW0pJoU88XFWLBd2O9PJPvEnDhSY/jQHfIroN5W5qGfPgFHKC4lkx0+9Vw/0IAkNHf1XBVInBkM9Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2655,9 +2655,9 @@ "optional": true }, "node_modules/bare-fs": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.2.tgz", - "integrity": "sha512-8wSeOia5B7LwD4+h465y73KOdj5QHsbbuoUfPBi+pXgFJIPuG7SsiOdJuijWMyfid49eD+WivpfY7KT8gbAzBA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", + "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -7479,6 +7479,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", "dev": true, "funding": [ { @@ -10145,19 +10146,19 @@ } }, "node_modules/webdriver": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.12.5.tgz", - "integrity": "sha512-CQCb1kDh52VtzPOIWc6XOdRz9q07LMAm9XwL+ABLSd0gueJq+GZoUTqHVX1YwVF0EQlFnw0JYJok0hxGH7m7cw==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.14.0.tgz", + "integrity": "sha512-0mVjxafQ5GNdK4l/FVmmmXGUfLHCSBE4Ml2LG23rxgmw53CThAos6h01UgIEINonxIzgKEmwfqJioo3/frbpbQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "^20.1.0", "@types/ws": "^8.5.3", - "@wdio/config": "9.12.5", + "@wdio/config": "9.14.0", "@wdio/logger": "9.4.4", - "@wdio/protocols": "9.12.5", - "@wdio/types": "9.12.3", - "@wdio/utils": "9.12.5", + "@wdio/protocols": "9.14.0", + "@wdio/types": "9.14.0", + "@wdio/utils": "9.14.0", "deepmerge-ts": "^7.0.3", "undici": "^6.20.1", "ws": "^8.8.0" @@ -10167,20 +10168,20 @@ } }, "node_modules/webdriverio": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.12.5.tgz", - "integrity": "sha512-ho7gEOdPkpMlZJ5fbCX6+zAllnVdYl8X9RZ4x3tDabf3ByEzReqexaTVou8ayWmNngGjarWlXX3ov1BIdhQTLQ==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.14.0.tgz", + "integrity": "sha512-GP0p6J+yjcCXF9uXW7HjB6IEh33OKmZcLTSg/W2rnVYSWgsUEYPujKSXe5I8q5a99QID7OOKNKVMfs5ANoZ2BA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", - "@wdio/config": "9.12.5", + "@wdio/config": "9.14.0", "@wdio/logger": "9.4.4", - "@wdio/protocols": "9.12.5", + "@wdio/protocols": "9.14.0", "@wdio/repl": "9.4.4", - "@wdio/types": "9.12.3", - "@wdio/utils": "9.12.5", + "@wdio/types": "9.14.0", + "@wdio/utils": "9.14.0", "archiver": "^7.0.1", "aria-query": "^5.3.0", "cheerio": "^1.0.0-rc.12", @@ -10197,7 +10198,7 @@ "rgb2hex": "0.2.5", "serialize-error": "^11.0.3", "urlpattern-polyfill": "^10.0.0", - "webdriver": "9.12.5" + "webdriver": "9.14.0" }, "engines": { "node": ">=18.20.0" From 3c7545769073c820dd6632bbfb75ad83b7ec395e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 15:57:53 +0100 Subject: [PATCH 12/32] chore(deps): bump mocha from 10.7.3 to 11.3.0 (#9067) Bumps [mocha](https://github.com/mochajs/mocha) from 10.7.3 to 11.3.0. - [Release notes](https://github.com/mochajs/mocha/releases) - [Changelog](https://github.com/mochajs/mocha/blob/main/CHANGELOG.md) - [Commits](https://github.com/mochajs/mocha/compare/v10.7.3...v11.3.0) --- updated-dependencies: - dependency-name: mocha dependency-version: 11.3.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 169 +++++++++++++++++++++++++++++++--------------- package.json | 2 +- 2 files changed, 116 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed5aa2b3f65..07e47a5033c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "http-server": "^14.0.0", "json5": "^2.2.0", "markdown-tables-to-json": "^0.1.7", - "mocha": "^10.0.0", + "mocha": "^11.3.0", "patch-package": "^8.0.0", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.0.0", @@ -2123,15 +2123,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-gray": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", @@ -7293,30 +7284,31 @@ } }, "node_modules/mocha": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.3.tgz", - "integrity": "sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.3.0.tgz", + "integrity": "sha512-J0RLIM89xi8y6l77bgbX+03PeBRDQCOVQpnwOcCN7b8hCmbh6JvGI2ZDJ5WMoHz+IaPU+S4lvTd0j51GmBAdgQ==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", + "chokidar": "^4.0.1", "debug": "^4.3.5", "diff": "^5.2.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", - "glob": "^8.1.0", + "glob": "^10.4.5", "he": "^1.2.0", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", "minimatch": "^5.1.6", "ms": "^2.1.3", + "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { @@ -7324,7 +7316,7 @@ "mocha": "bin/mocha.js" }, "engines": { - "node": ">= 14.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/mocha/node_modules/brace-expansion": { @@ -7332,35 +7324,93 @@ "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/mocha/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/mocha/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "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": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "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/mocha/node_modules/glob/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": ">=12" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mocha/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==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/mocha/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/mocha/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -7368,46 +7418,57 @@ "node": ">=10" } }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/mocha/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==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "has-flag": "^4.0.0" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.18" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "node_modules/mocha/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, + "license": "ISC" + }, + "node_modules/mocha/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/mocha/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/monaco-editor": { diff --git a/package.json b/package.json index 523b5590fad..5d44b45bf9e 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "http-server": "^14.0.0", "json5": "^2.2.0", "markdown-tables-to-json": "^0.1.7", - "mocha": "^10.0.0", + "mocha": "^11.3.0", "patch-package": "^8.0.0", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.0.0", From 6dbd7b84beca0983120edbf87464d6d3d013fb03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 16:04:55 +0100 Subject: [PATCH 13/32] chore(deps-dev): bump undici in the npm_and_yarn group (#8744) Bumps the npm_and_yarn group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 6.21.0 to 6.21.1 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v6.21.0...v6.21.1) --- updated-dependencies: - dependency-name: undici dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07e47a5033c..79b94f8e034 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9904,10 +9904,11 @@ } }, "node_modules/undici": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", - "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18.17" } From e4d7245e8673ffb3c53f7aa1043a42691002116a Mon Sep 17 00:00:00 2001 From: RoboErikG Date: Wed, 21 May 2025 16:42:22 -0700 Subject: [PATCH 14/32] fix: Visit all nodes in getNextSibling and getPreviousSibling (#9080) * WIP on line by line navigation Doesn't work, likely due to isValid check. * Add all inputs to the list of siblings * Fix formatting * Add tests * Remove dupe keys --- core/keyboard_nav/block_navigation_policy.ts | 22 ++-- tests/mocha/navigation_test.js | 104 +++++++++++++++++++ 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index d0f6d230b19..1a959425477 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -57,7 +57,7 @@ export class BlockNavigationPolicy implements INavigationPolicy { * Returns the next peer node of the given block. * * @param current The block to find the following element of. - * @returns The first block of the next stack if the given block is a terminal + * @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 { @@ -70,12 +70,10 @@ export class BlockNavigationPolicy implements INavigationPolicy { let siblings: (BlockSvg | Field)[] = []; if (parent instanceof BlockSvg) { for (let i = 0, input; (input = parent.inputList[i]); i++) { - if (input.connection) { - siblings.push(...input.fieldRow); - const child = input.connection.targetBlock(); - if (child) { - siblings.push(child as BlockSvg); - } + siblings.push(...input.fieldRow); + const child = input.connection?.targetBlock(); + if (child) { + siblings.push(child as BlockSvg); } } } else if (parent instanceof WorkspaceSvg) { @@ -114,12 +112,10 @@ export class BlockNavigationPolicy implements INavigationPolicy { let siblings: (BlockSvg | Field)[] = []; if (parent instanceof BlockSvg) { for (let i = 0, input; (input = parent.inputList[i]); i++) { - if (input.connection) { - siblings.push(...input.fieldRow); - const child = input.connection.targetBlock(); - if (child) { - siblings.push(child as BlockSvg); - } + siblings.push(...input.fieldRow); + const child = input.connection?.targetBlock(); + if (child) { + siblings.push(child as BlockSvg); } } } else if (parent instanceof WorkspaceSvg) { diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index 3675c08d602..aa8ab2c19b2 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -283,6 +283,61 @@ suite('Navigation', function () { 'tooltip': '', 'helpUrl': '', }, + { + '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 noNextConnection = this.workspace.newBlock('top_connection'); const fieldAndInputs = this.workspace.newBlock('fields_and_input'); @@ -313,6 +368,27 @@ suite('Navigation', function () { const outputNextBlock = this.workspace.newBlock('output_next'); this.blocks.secondBlock = secondBlock; this.blocks.outputNextBlock = outputNextBlock; + + const buttonBlock = this.workspace.newBlock('buttons'); + const buttonInput1 = this.workspace.newBlock('field_input'); + const buttonInput2 = this.workspace.newBlock('field_input'); + buttonBlock.inputList[0].connection.connect( + buttonInput1.outputConnection, + ); + buttonBlock.inputList[2].connection.connect( + buttonInput2.outputConnection, + ); + // Make buttons by adding a click handler + const clickHandler = function () { + return; + }; + buttonBlock.getField('BUTTON1').setOnClickHandler(clickHandler); + buttonBlock.getField('BUTTON2').setOnClickHandler(clickHandler); + buttonBlock.getField('BUTTON3').setOnClickHandler(clickHandler); + this.blocks.buttonBlock = buttonBlock; + this.blocks.buttonInput1 = buttonInput1; + this.blocks.buttonInput2 = buttonInput2; + this.workspace.cleanUp(); }); suite('Next', function () { @@ -427,6 +503,20 @@ suite('Navigation', function () { const nextNode = this.navigator.getNextSibling(icons[0]); assert.isNull(nextNode); }); + test('fromBlockToFieldInNextInput', function () { + const field = this.blocks.buttonBlock.getField('BUTTON2'); + const prevNode = this.navigator.getNextSibling( + this.blocks.buttonInput1, + ); + assert.equal(prevNode, field); + }); + test('fromBlockToFieldSkippingInput', function () { + const field = this.blocks.buttonBlock.getField('BUTTON3'); + const prevNode = this.navigator.getNextSibling( + this.blocks.buttonInput2, + ); + assert.equal(prevNode, field); + }); }); suite('Previous', function () { @@ -541,6 +631,20 @@ suite('Navigation', function () { const prevNode = this.navigator.getPreviousSibling(icons[0]); assert.isNull(prevNode); }); + test('fromBlockToFieldInSameInput', function () { + const field = this.blocks.buttonBlock.getField('BUTTON1'); + const prevNode = this.navigator.getPreviousSibling( + this.blocks.buttonInput1, + ); + assert.equal(prevNode, field); + }); + test('fromBlockToFieldInPrevInput', function () { + const field = this.blocks.buttonBlock.getField('BUTTON2'); + const prevNode = this.navigator.getPreviousSibling( + this.blocks.buttonInput2, + ); + assert.equal(prevNode, field); + }); }); suite('In', function () { From 4f3eadef337b14d2fd2ac0dbc37dc566bf3260ec Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 22 May 2025 09:40:32 -0700 Subject: [PATCH 15/32] fix: Update `focusNode` to self correct focus (#9082) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes https://github.com/google/blockly-keyboard-experimentation/issues/87 ### Proposed Changes This updates `FocusManager.focusNode()` to automatically defocus its internal state if it detects that DOM focus (per `document.activeElement`) doesn't match its own internal focus. It also updates `FocusManager` to avoid duplicate self calls to `focusNode()`. ### Reason for Changes This is a robustness improvement for `focusNode` that is nice to keep as a "if all else fails" mechanism, but it's currently a hacky workaround to https://github.com/google/blockly-keyboard-experimentation/issues/87. #9081 is tracking introducing a long-term fix for the desynchronizing problem, but that's likely to be potentially much harder to solve and this at least introduces a reasonable correction. From a stability perspective, it seems likely that there are multiple classes of failures covered by this fix. Essentially the browser behavior difference in Firefox and Safari over Chrome is that the former do not fire a focus change event when a focused element is removed from the DOM (leading to `FocusManager` getting out of sync). There may be other such cases when a focus event isn't fired, so this robustness improvement at least ensures eventual consistency so long as `focusNode()` is called (and, fortunately, that's done a lot now). While this is a nice robustness improvement, it's not a perfect replacement for a real fix. For the time between `FocusManager` getting out of sync and `focusNode` getting called, `getFocusedNode` will _not_ match the actual element holding focus. The primary class of issues known is when a DOM element is being moved, and in these cases `focusNode` _is_ called. If there are other such unknown cases where a desync can happen, **`getFocusedNode()` will remain wrong until a later `focusNode()` call**. Note one other change: originally implemented but removed in earlier PRs for `FocusManager`, this change also includes ensuring `focusNode()` isn't called multiple times for a single request to focus a node. Current logic results in a call to `focusNode()` calling a node's `focus()` which then processes a second call to `focusNode()` (which is fully executed because `FocusManager.focusedNode` isn't updated until after the call to `focus()`). This doesn't actually correct any state, but it's more efficient and provides some resilience against potential logic issues from calling node/tree callbacks multiple times (which was observed up to 3 times in some cases). ### Test Coverage This has been tested via the keyboard navigation experimental plugin's test environment (with Firefox), plus new unit tests. Note the new test for directly verifying desyncing logic is contrived, but it should be perfectly testing the exact scenario that's being observed on Firefox/Safari. A separate test was added for the existing behavior of focusing a different node still correcting `FocusManager` state even if it was desynced (the bug only happens for the same node being refocused). New tests were also added for the various lifecycle callbacks (to ensure they aren't called multiple times). All of the new tests were verified to fail without the two fixes in place (they were verified in isolation), minus the test for focusing a second node when desynced (since that should pass regardless of the new fixes). Some basic simple playground testing was done, as well, just to verify nothing obvious was broken around selection, gestures, and copy/paste. ### Documentation No new documentation should be needed here. ### Additional Information This wasn't explicitly tested in Safari since I only have access to Chrome and Firefox, but I will ask someone else on the team to verify this for me after merging if it isn't checked sooner. --- core/focus_manager.ts | 26 +++++++++- tests/mocha/focus_manager_test.js | 85 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/core/focus_manager.ts b/core/focus_manager.ts index c0139aec08d..198e1f0747d 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -63,12 +63,16 @@ export class FocusManager { private currentlyHoldsEphemeralFocus: boolean = false; private lockFocusStateChanges: boolean = false; private recentlyLostAllFocus: boolean = false; + private isUpdatingFocusedNode: boolean = false; constructor( addGlobalEventListener: (type: string, listener: EventListener) => void, ) { // Note that 'element' here is the element *gaining* focus. const maybeFocus = (element: Element | EventTarget | null) => { + // Skip processing the event if the focused node is currently updating. + if (this.isUpdatingFocusedNode) return; + this.recentlyLostAllFocus = !element; let newNode: IFocusableNode | null | undefined = null; if (element instanceof HTMLElement || element instanceof SVGElement) { @@ -240,7 +244,23 @@ export class FocusManager { */ focusNode(focusableNode: IFocusableNode): void { this.ensureManagerIsUnlocked(); - if (this.focusedNode === focusableNode) return; // State is unchanged. + if (!this.currentlyHoldsEphemeralFocus) { + // Disable state syncing from DOM events since possible calls to focus() + // below will loop a call back to focusNode(). + this.isUpdatingFocusedNode = true; + } + + // Double check that state wasn't desynchronized in the background. See: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + // This is only done for the case where the same node is being focused twice + // since other cases should automatically correct (due to the rest of the + // routine running as normal). + const prevFocusedElement = this.focusedNode?.getFocusableElement(); + const hasDesyncedState = prevFocusedElement !== document.activeElement; + if (this.focusedNode === focusableNode && !hasDesyncedState) { + return; // State is unchanged. + } + if (!focusableNode.canBeFocused()) { // This node can't be focused. console.warn("Trying to focus a node that can't be focused."); @@ -292,6 +312,10 @@ export class FocusManager { this.activelyFocusNode(nodeToFocus, prevTree ?? null); } this.updateFocusedNode(nodeToFocus); + if (!this.currentlyHoldsEphemeralFocus) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } } /** diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index b1cfb029a87..cd89d1351b2 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -419,6 +419,91 @@ suite('FocusManager', function () { const currentNode = this.focusManager.getFocusedNode(); assert.strictEqual(currentNode, this.testFocusableTree1Node2); }); + + test('restores focus when element quietly loses focus', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + // Remove the FocusManager's listeners to simulate not receiving a focus + // event when focus is lost. This can happen in Firefox and Safari when an + // element is removed and then re-added to the DOM. This is a contrived + // setup to achieve the same outcome on all browsers. For context, see: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + for (const registeredListener of this.globalDocumentEventListeners) { + const eventType = registeredListener.type; + const eventListener = registeredListener.listener; + document.removeEventListener(eventType, eventListener); + } + document.body.focus(); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + const currentNode = this.focusManager.getFocusedNode(); + const currentElem = currentNode?.getFocusableElement(); + assert.strictEqual(currentNode, this.testFocusableTree1Node1); + assert.strictEqual(document.activeElement, currentElem); + }); + + test('restores focus when element and new node focused', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + // Remove the FocusManager's listeners to simulate not receiving a focus + // event when focus is lost. This can happen in Firefox and Safari when an + // element is removed and then re-added to the DOM. This is a contrived + // setup to achieve the same outcome on all browsers. For context, see: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + for (const registeredListener of this.globalDocumentEventListeners) { + const eventType = registeredListener.type; + const eventListener = registeredListener.listener; + document.removeEventListener(eventType, eventListener); + } + document.body.focus(); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + const currentNode = this.focusManager.getFocusedNode(); + const currentElem = currentNode?.getFocusableElement(); + assert.strictEqual(currentNode, this.testFocusableTree1Node2); + assert.strictEqual(document.activeElement, currentElem); + }); + + test('for unfocused node calls onNodeFocus once', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeFocus'); + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + assert.strictEqual(this.testFocusableTree1Node1.onNodeFocus.callCount, 1); + }); + + test('for previously focused node calls onNodeBlur once', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeBlur'); + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + assert.strictEqual(this.testFocusableTree1Node1.onNodeBlur.callCount, 1); + }); + + test('for unfocused tree calls onTreeFocus once', function () { + sinon.spy(this.testFocusableTree1, 'onTreeFocus'); + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + assert.strictEqual(this.testFocusableTree1.onTreeFocus.callCount, 1); + }); + + test('for previously focused tree calls onTreeBlur once', function () { + sinon.spy(this.testFocusableTree1, 'onTreeBlur'); + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + assert.strictEqual(this.testFocusableTree1.onTreeBlur.callCount, 1); + }); }); suite('getFocusManager()', function () { From 056aaf32d0e7ab1d2b3721e338d026d18521e49b Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 22 May 2025 15:56:57 -0700 Subject: [PATCH 16/32] feat: Add more ephemeral overrides for drop-downs. (#9086) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9078 Fixes part of #8915 (new tests) ### Proposed Changes Exposes the ability to disable ephemeral focus management for drop-down divs that are shown using `showPositionedByBlock` or `showPositionedByField`. Previously, this was only supported via `show`, but the former methods are also used externally. This allows the underlying issue reported by #9078 to be fixed downstream for cases when both the widget and drop-down divs are opened simultaneously. This PR also introduces tab indexes for both widget and drop-down divs (which were noticed missing when adding tests). This is because, currently, taking ephemeral focus on for a node that doesn't have a tab index will do nothing. This fix is useful for future screen reader work, and doesn't have obvious impacts on existing core or keyboard navigation behaviors (per testing and reasoning). ### Reason for Changes Exposing the ability to disable ephemeral focus management for all public API entrypoints for showing the divs is crucial for providing the maximum flexibility when downstream apps use both the widget and drop-down divs together. This should ensure that all of these cases can be correctly managed in the same way as https://github.com/google/blockly-samples/pull/2521. ### Test Coverage This introduces a bunch of new tests that were missing originally for both widget and drop-down div (including specifically verifying ephemeral focus). As part of the drop-down div tests, it also introduces actual positioning logic. This isn't great, but it's somewhat reasonable and robust against page changes (since the actual mocha results can move where the elements will end up on the page). These changes have also been manually tested with both the core simple playground and the keyboard experiment plugin's test environment with no noticed regressions in either. The plugin's tests have also been run against these changes to ensure no new breakages have been introduced. ### Documentation No documentation changes beyond the code ones introduced in this PR should be needed. ### Additional Information The new tests may actually act as a basis for avoiding the test backdoor that's used today for the positioning tests for drop-down div tests. This doesn't replace those existing tests nor does it cover other behaviors and entrypoints that would be worth testing, but testing ephemeral focus is a nice improvement (especially in the context of what this PR is fixing). --- core/dropdowndiv.ts | 34 +++- core/widgetdiv.ts | 1 + tests/mocha/dropdowndiv_test.js | 317 +++++++++++++++++++++++++++++++- tests/mocha/widget_div_test.js | 132 +++++++++++++ 4 files changed, 472 insertions(+), 12 deletions(-) diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index 894724d4448..c7b0da7116e 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -122,6 +122,7 @@ export function createDom() { } div = document.createElement('div'); div.className = 'blocklyDropDownDiv'; + div.tabIndex = -1; const parentDiv = common.getParentContainer() || document.body; parentDiv.appendChild(div); @@ -192,6 +193,11 @@ export function setColour(backgroundColour: string, borderColour: string) { * @param block Block to position the drop-down around. * @param opt_onHide Optional callback for when the drop-down is hidden. * @param opt_secondaryYOffset Optional Y offset for above-block positioning. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the drop-down div's lifetime. Note that if a false value is + * passed in here then callers should manage ephemeral focus directly + * otherwise focus may not properly restore when the widget closes. Defaults + * to true. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByBlock( @@ -199,10 +205,12 @@ export function showPositionedByBlock( block: BlockSvg, opt_onHide?: () => void, opt_secondaryYOffset?: number, + manageEphemeralFocus: boolean = true, ): boolean { return showPositionedByRect( getScaledBboxOfBlock(block), field as Field, + manageEphemeralFocus, opt_onHide, opt_secondaryYOffset, ); @@ -217,17 +225,24 @@ export function showPositionedByBlock( * @param field The field to position the dropdown against. * @param opt_onHide Optional callback for when the drop-down is hidden. * @param opt_secondaryYOffset Optional Y offset for above-block positioning. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the drop-down div's lifetime. Note that if a false value is + * passed in here then callers should manage ephemeral focus directly + * otherwise focus may not properly restore when the widget closes. Defaults + * to true. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByField( field: Field, opt_onHide?: () => void, opt_secondaryYOffset?: number, + manageEphemeralFocus: boolean = true, ): boolean { positionToField = true; return showPositionedByRect( getScaledBboxOfField(field as Field), field as Field, + manageEphemeralFocus, opt_onHide, opt_secondaryYOffset, ); @@ -271,16 +286,15 @@ function getScaledBboxOfField(field: Field): Rect { * @param manageEphemeralFocus Whether ephemeral focus should be managed * according to the drop-down div's lifetime. Note that if a false value is * passed in here then callers should manage ephemeral focus directly - * otherwise focus may not properly restore when the widget closes. Defaults - * to true. + * otherwise focus may not properly restore when the widget closes. * @returns True if the menu rendered below block; false if above. */ function showPositionedByRect( bBox: Rect, field: Field, + manageEphemeralFocus: boolean, opt_onHide?: () => void, opt_secondaryYOffset?: number, - manageEphemeralFocus: boolean = true, ): boolean { // If we can fit it, render below the block. const primaryX = bBox.left + (bBox.right - bBox.left) / 2; @@ -352,10 +366,6 @@ export function show( dom.addClass(div, renderedClassName); dom.addClass(div, themeClassName); - if (manageEphemeralFocus) { - returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); - } - // When we change `translate` multiple times in close succession, // Chrome may choose to wait and apply them all at once. // Since we want the translation to initial X, Y to be immediate, @@ -364,7 +374,15 @@ export function show( // making the dropdown appear to fly in from (0, 0). // Using both `left`, `top` for the initial translation and then `translate` // for the animated transition to final X, Y is a workaround. - return positionInternal(primaryX, primaryY, secondaryX, secondaryY); + const atOrigin = positionInternal(primaryX, primaryY, secondaryX, secondaryY); + + // Ephemeral focus must happen after the div is fully visible in order to + // ensure that it properly receives focus. + if (manageEphemeralFocus) { + returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); + } + + return atOrigin; } const internal = { diff --git a/core/widgetdiv.ts b/core/widgetdiv.ts index 936983e8f10..83e2384f510 100644 --- a/core/widgetdiv.ts +++ b/core/widgetdiv.ts @@ -71,6 +71,7 @@ export function createDom() { } else { containerDiv = document.createElement('div') as HTMLDivElement; containerDiv.className = containerClassName; + containerDiv.tabIndex = -1; } container.appendChild(containerDiv!); diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index 32109bfcadd..451a726d6ee 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {Rect} from '../../build/src/core/utils/rect.js'; +import * as style from '../../build/src/core/utils/style.js'; import {assert} from '../../node_modules/chai/chai.js'; import { sharedTestSetup, @@ -11,9 +13,32 @@ import { } from './test_helpers/setup_teardown.js'; suite('DropDownDiv', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv'); + this.setUpBlockWithField = function () { + const blockJson = { + 'type': 'text', + 'id': 'block_id', + 'x': 10, + 'y': 20, + 'fields': { + 'TEXT': '', + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + return this.workspace.getBlockById('block_id'); + }; + // The workspace needs to be visible for focus-specific tests. + document.getElementById('blocklyDiv').style.visibility = 'visible'; + }); + teardown(function () { + sharedTestTeardown.call(this); + document.getElementById('blocklyDiv').style.visibility = 'hidden'; + }); + suite('Positioning', function () { setup(function () { - sharedTestSetup.call(this); this.boundsStub = sinon .stub(Blockly.DropDownDiv.TEST_ONLY, 'getBoundsInfo') .returns({ @@ -41,9 +66,6 @@ suite('DropDownDiv', function () { return 0; }); }); - teardown(function () { - sharedTestTeardown.call(this); - }); test('Below, in Bounds', function () { const metrics = Blockly.DropDownDiv.TEST_ONLY.getPositionMetrics( 50, @@ -113,4 +135,291 @@ suite('DropDownDiv', function () { assert.isNotOk(metrics.arrowAtTop); }); }); + + suite('show()', function () { + test('without bounds set throws error', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + + const errorMsgRegex = /Cannot read properties of null.+?/; + assert.throws( + () => Blockly.DropDownDiv.show(field, false, 50, 60, 70, 80, false), + errorMsgRegex, + ); + }); + + test('with bounds set positions and shows div near specified location', function () { + Blockly.DropDownDiv.setBoundsElement(document.body); + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + + Blockly.DropDownDiv.show(field, false, 50, 60, 70, 80, false); + + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + assert.strictEqual(dropDownDivElem.style.left, '45px'); + assert.strictEqual(dropDownDivElem.style.top, '60px'); + }); + }); + + suite('showPositionedByField()', function () { + test('shows div near field', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const fieldBounds = field.getScaledBBox(); + + Blockly.DropDownDiv.showPositionedByField(field); + + // The div should show below the field and centered horizontally. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + const divWidth = style.getSize(dropDownDivElem).width; + const expectedLeft = Math.floor( + fieldBounds.left + fieldBounds.getWidth() / 2 - divWidth / 2, + ); + const expectedTop = Math.floor(fieldBounds.bottom); // Should show beneath. + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + assert.strictEqual(dropDownDivElem.style.left, `${expectedLeft}px`); + assert.strictEqual(dropDownDivElem.style.top, `${expectedTop}px`); + }); + + test('with hide callback does not call callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + + Blockly.DropDownDiv.showPositionedByField(field, onHideCallback); + + // Simply showing the div should never call the hide callback. + assert.strictEqual(onHideCallback.callCount, 0); + }); + + test('without managed ephemeral focus does not change focused node', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.DropDownDiv.showPositionedByField(field, null, null, false); + + // Since managing ephemeral focus is disabled the current focused node shouldn't be changed. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('with managed ephemeral focus focuses drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.DropDownDiv.showPositionedByField(field, null, null, true); + + // Managing ephemeral focus won't change getFocusedNode() but will change the actual element + // with DOM focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, dropDownDivElem); + }); + }); + + suite('showPositionedByBlock()', function () { + test('shows div near block', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + // Note that the offset must be computed before showing the div since otherwise it can move + // slightly after the div is shown. + const blockOffset = style.getPageOffset(block.getSvgRoot()); + + Blockly.DropDownDiv.showPositionedByBlock(field, block); + + // The div should show below the block and centered horizontally. + const blockLocalBounds = block.getBoundingRectangle(); + const blockBounds = Rect.createFromPoint( + blockOffset, + blockLocalBounds.getWidth(), + blockLocalBounds.getHeight(), + ); + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + const divWidth = style.getSize(dropDownDivElem).width; + const expectedLeft = Math.floor( + blockBounds.left + blockBounds.getWidth() / 2 - divWidth / 2, + ); + const expectedTop = Math.floor(blockBounds.bottom); // Should show beneath. + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + assert.strictEqual(dropDownDivElem.style.left, `${expectedLeft}px`); + assert.strictEqual(dropDownDivElem.style.top, `${expectedTop}px`); + }); + + test('with hide callback does not call callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + + Blockly.DropDownDiv.showPositionedByBlock(field, block, onHideCallback); + + // Simply showing the div should never call the hide callback. + assert.strictEqual(onHideCallback.callCount, 0); + }); + + test('without managed ephemeral focus does not change focused node', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + false, + ); + + // Since managing ephemeral focus is disabled the current focused node shouldn't be changed. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('with managed ephemeral focus focuses drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.DropDownDiv.showPositionedByBlock(field, block, null, null, true); + + // Managing ephemeral focus won't change getFocusedNode() but will change the actual element + // with DOM focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, dropDownDivElem); + }); + }); + + suite('hideWithoutAnimation()', function () { + test('when not showing drop-down div keeps opacity at 0', function () { + Blockly.DropDownDiv.hideWithoutAnimation(); + + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); + + suite('for div positioned by field', function () { + test('hides div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.DropDownDiv.showPositionedByField(field); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Technically this will trigger a CSS animation, but the property is still set to 0. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); + + test('hide callback calls callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + Blockly.DropDownDiv.showPositionedByField(field, onHideCallback); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div should trigger the hide callback. + assert.strictEqual(onHideCallback.callCount, 1); + }); + + test('without ephemeral focus does not change focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, false); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div shouldn't change what would have already been focused. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('with ephemeral focus restores DOM focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, true); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div should restore focus back to the block. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + }); + + suite('for div positioned by block', function () { + test('hides div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.DropDownDiv.showPositionedByBlock(field, block); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Technically this will trigger a CSS animation, but the property is still set to 0. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); + + test('hide callback calls callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + Blockly.DropDownDiv.showPositionedByBlock(field, block, onHideCallback); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div should trigger the hide callback. + assert.strictEqual(onHideCallback.callCount, 1); + }); + + test('without ephemeral focus does not change focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + false, + ); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div shouldn't change what would have already been focused. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('with ephemeral focus restores DOM focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + true, + ); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div should restore focus back to the block. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + }); + }); }); diff --git a/tests/mocha/widget_div_test.js b/tests/mocha/widget_div_test.js index 94fb855392d..836a68282a0 100644 --- a/tests/mocha/widget_div_test.js +++ b/tests/mocha/widget_div_test.js @@ -13,9 +13,26 @@ import { suite('WidgetDiv', function () { setup(function () { sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv'); + this.setUpBlockWithField = function () { + const blockJson = { + 'type': 'text', + 'id': 'block_id', + 'x': 10, + 'y': 20, + 'fields': { + 'TEXT': '', + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + return this.workspace.getBlockById('block_id'); + }; + // The workspace needs to be visible for focus-specific tests. + document.getElementById('blocklyDiv').style.visibility = 'visible'; }); teardown(function () { sharedTestTeardown.call(this); + document.getElementById('blocklyDiv').style.visibility = 'hidden'; }); suite('positionWithAnchor', function () { @@ -269,4 +286,119 @@ suite('WidgetDiv', function () { }); }); }); + + suite('show()', function () { + test('shows nowhere', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + + Blockly.WidgetDiv.show(field, false, () => {}); + + // By default the div will not have a position. + const widgetDivElem = document.querySelector('.blocklyWidgetDiv'); + assert.strictEqual(widgetDivElem.style.display, 'block'); + assert.strictEqual(widgetDivElem.style.left, ''); + assert.strictEqual(widgetDivElem.style.top, ''); + }); + + test('with hide callback does not call callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + + Blockly.WidgetDiv.show(field, false, () => {}); + + // Simply showing the div should never call the hide callback. + assert.strictEqual(onHideCallback.callCount, 0); + }); + + test('without managed ephemeral focus does not change focused node', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.WidgetDiv.show(field, false, () => {}, null, false); + + // Since managing ephemeral focus is disabled the current focused node shouldn't be changed. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('with managed ephemeral focus focuses widget div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.WidgetDiv.show(field, false, () => {}, null, true); + + // Managing ephemeral focus won't change getFocusedNode() but will change the actual element + // with DOM focus. + const widgetDivElem = document.querySelector('.blocklyWidgetDiv'); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, widgetDivElem); + }); + }); + + suite('hide()', function () { + test('initially keeps display empty', function () { + Blockly.WidgetDiv.hide(); + + // The display property starts as empty and stays that way until an owner is attached. + const widgetDivElem = document.querySelector('.blocklyWidgetDiv'); + assert.strictEqual(widgetDivElem.style.display, ''); + }); + + test('for showing div hides div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.WidgetDiv.show(field, false, () => {}); + + Blockly.WidgetDiv.hide(); + + // Technically this will trigger a CSS animation, but the property is still set to 0. + const widgetDivElem = document.querySelector('.blocklyWidgetDiv'); + assert.strictEqual(widgetDivElem.style.display, 'none'); + }); + + test('for showing div and hide callback calls callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + Blockly.WidgetDiv.show(field, false, onHideCallback); + + Blockly.WidgetDiv.hide(); + + // Hiding the div should trigger the hide callback. + assert.strictEqual(onHideCallback.callCount, 1); + }); + + test('for showing div without ephemeral focus does not change focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.WidgetDiv.show(field, false, () => {}, null, false); + + Blockly.WidgetDiv.hide(); + + // Hiding the div shouldn't change what would have already been focused. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('for showing div with ephemeral focus restores DOM focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.WidgetDiv.show(field, false, () => {}, null, true); + + Blockly.WidgetDiv.hide(); + + // Hiding the div should restore focus back to the block. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + }); }); From cc9384ae87aeb25c47c158261fe1dc8d381240a4 Mon Sep 17 00:00:00 2001 From: RoboErikG Date: Fri, 23 May 2025 13:11:30 -0700 Subject: [PATCH 17/32] fix: Don't visit collapsed blocks (#9090) * WIP on line by line navigation Doesn't work, likely due to isValid check. * Add all inputs to the list of siblings * Fix formatting * Add tests * Remove dupe keys * fix: Make blocks with display: none not focusable * Undo changes to canBeFocused * Don't traverse inputs that are invisible --- core/keyboard_nav/block_navigation_policy.ts | 9 ++++ tests/mocha/navigation_test.js | 43 ++++++++++++++++---- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 1a959425477..af7eadc09d7 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -25,6 +25,9 @@ export class BlockNavigationPolicy implements INavigationPolicy { if (icons.length) return icons[0]; for (const input of current.inputList) { + if (!input.isVisible()) { + continue; + } for (const field of input.fieldRow) { return field; } @@ -70,6 +73,9 @@ export class BlockNavigationPolicy implements INavigationPolicy { let siblings: (BlockSvg | Field)[] = []; if (parent instanceof BlockSvg) { for (let i = 0, input; (input = parent.inputList[i]); i++) { + if (!input.isVisible()) { + continue; + } siblings.push(...input.fieldRow); const child = input.connection?.targetBlock(); if (child) { @@ -112,6 +118,9 @@ export class BlockNavigationPolicy implements INavigationPolicy { let siblings: (BlockSvg | Field)[] = []; if (parent instanceof BlockSvg) { for (let i = 0, input; (input = parent.inputList[i]); i++) { + if (!input.isVisible()) { + continue; + } siblings.push(...input.fieldRow); const child = input.connection?.targetBlock(); if (child) { diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index aa8ab2c19b2..3f98d4ca57c 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -369,15 +369,26 @@ suite('Navigation', function () { this.blocks.secondBlock = secondBlock; this.blocks.outputNextBlock = outputNextBlock; - const buttonBlock = this.workspace.newBlock('buttons'); - const buttonInput1 = this.workspace.newBlock('field_input'); - const buttonInput2 = this.workspace.newBlock('field_input'); + const buttonBlock = this.workspace.newBlock('buttons', 'button_block'); + const buttonInput1 = this.workspace.newBlock( + 'field_input', + 'button_input1', + ); + const buttonInput2 = this.workspace.newBlock( + 'field_input', + 'button_input2', + ); + const buttonNext = this.workspace.newBlock( + 'input_statement', + 'button_next', + ); buttonBlock.inputList[0].connection.connect( buttonInput1.outputConnection, ); buttonBlock.inputList[2].connection.connect( buttonInput2.outputConnection, ); + buttonBlock.nextConnection.connect(buttonNext.previousConnection); // Make buttons by adding a click handler const clickHandler = function () { return; @@ -388,6 +399,7 @@ suite('Navigation', function () { this.blocks.buttonBlock = buttonBlock; this.blocks.buttonInput1 = buttonInput1; this.blocks.buttonInput2 = buttonInput2; + this.blocks.buttonNext = buttonNext; this.workspace.cleanUp(); }); @@ -505,17 +517,22 @@ suite('Navigation', function () { }); test('fromBlockToFieldInNextInput', function () { const field = this.blocks.buttonBlock.getField('BUTTON2'); - const prevNode = this.navigator.getNextSibling( + const nextNode = this.navigator.getNextSibling( this.blocks.buttonInput1, ); - assert.equal(prevNode, field); + assert.equal(nextNode, field); }); test('fromBlockToFieldSkippingInput', function () { const field = this.blocks.buttonBlock.getField('BUTTON3'); - const prevNode = this.navigator.getNextSibling( + const nextNode = this.navigator.getNextSibling( this.blocks.buttonInput2, ); - assert.equal(prevNode, field); + assert.equal(nextNode, field); + }); + test('skipsChildrenOfCollapsedBlocks', function () { + this.blocks.buttonBlock.setCollapsed(true); + const nextNode = this.navigator.getNextSibling(this.blocks.buttonBlock); + assert.equal(nextNode.id, this.blocks.buttonNext.id); }); }); @@ -645,6 +662,13 @@ suite('Navigation', function () { ); assert.equal(prevNode, field); }); + test('skipsChildrenOfCollapsedBlocks', function () { + this.blocks.buttonBlock.setCollapsed(true); + const prevNode = this.navigator.getPreviousSibling( + this.blocks.buttonNext, + ); + assert.equal(prevNode.id, this.blocks.buttonBlock.id); + }); }); suite('In', function () { @@ -725,6 +749,11 @@ suite('Navigation', function () { const inNode = this.navigator.getFirstChild(icons[0]); assert.isNull(inNode); }); + test('skipsChildrenOfCollapsedBlocks', function () { + this.blocks.buttonBlock.setCollapsed(true); + const inNode = this.navigator.getFirstChild(this.blocks.buttonBlock); + assert.isNull(inNode); + }); }); suite('Out', function () { From ff2ec1185107b56417818466d50c39a052d3769e Mon Sep 17 00:00:00 2001 From: John Nesky Date: Tue, 27 May 2025 11:46:39 -0700 Subject: [PATCH 18/32] feat: Paste where context menu was opened (#9093) --- core/shortcut_items.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index a16e22aa33b..88d4228f934 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -17,6 +17,7 @@ import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; 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 {WorkspaceSvg} from './workspace_svg.js'; /** @@ -212,8 +213,22 @@ export function registerPaste() { preconditionFn(workspace) { return !workspace.isReadOnly() && !Gesture.inProgress(); }, - callback() { + callback(workspace: WorkspaceSvg, e: Event) { if (!copyData || !copyWorkspace) return false; + + if (e instanceof PointerEvent) { + // The event that triggers a shortcut would conventionally be a KeyboardEvent. + // However, it may be a PointerEvent if a context menu item was used as a + // wrapper for this callback, in which case the new block(s) should be pasted + // at the mouse coordinates where the menu was opened, and this PointerEvent + // is where the menu was opened. + const mouseCoords = svgMath.screenToWsCoordinates( + copyWorkspace, + new Coordinate(e.clientX, e.clientY), + ); + return !!clipboard.paste(copyData, copyWorkspace, mouseCoords); + } + if (!copyCoords) { // If we don't have location data about the original copyable, let the // paster determine position. From ab153726836d651fd54c978950096ffe372a874c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 11:57:45 -0700 Subject: [PATCH 19/32] chore(deps): bump typescript-eslint from 8.31.1 to 8.32.1 (#9095) Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.31.1 to 8.32.1. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.32.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: typescript-eslint dependency-version: 8.32.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 134 +++++++++++++++++++++++++--------------------- 1 file changed, 74 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index 79b94f8e034..b48c9443126 100644 --- a/package-lock.json +++ b/package-lock.json @@ -397,16 +397,20 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "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" } @@ -1541,21 +1545,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz", - "integrity": "sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", + "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/type-utils": "8.31.1", - "@typescript-eslint/utils": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/type-utils": "8.32.1", + "@typescript-eslint/utils": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1570,17 +1574,27 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz", - "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", + "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4" }, "engines": { @@ -1596,14 +1610,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.1.tgz", - "integrity": "sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", + "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1" + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1614,16 +1628,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.1.tgz", - "integrity": "sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", + "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/utils": "8.31.1", + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/utils": "8.32.1", "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1638,9 +1652,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.1.tgz", - "integrity": "sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", + "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", "dev": true, "license": "MIT", "engines": { @@ -1652,20 +1666,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.1.tgz", - "integrity": "sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", + "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1705,9 +1719,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -1718,16 +1732,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.1.tgz", - "integrity": "sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", + "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1742,13 +1756,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.1.tgz", - "integrity": "sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", + "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/types": "8.32.1", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -9839,15 +9853,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.31.1.tgz", - "integrity": "sha512-j6DsEotD/fH39qKzXTQRwYYWlt7D+0HmfpOK+DVhwJOFLcdmn92hq3mBb7HlKJHbjjI/gTOqEcc9d6JfpFf/VA==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.1.tgz", + "integrity": "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.31.1", - "@typescript-eslint/parser": "8.31.1", - "@typescript-eslint/utils": "8.31.1" + "@typescript-eslint/eslint-plugin": "8.32.1", + "@typescript-eslint/parser": "8.32.1", + "@typescript-eslint/utils": "8.32.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" From d2c4016fcc271a93e7be543fd032b446441f67bd Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 27 May 2025 11:57:58 -0700 Subject: [PATCH 20/32] fix: Fix bug that prevented using keyboard shortcuts when the DropDownDiv is open. (#9085) * fix: Fix bug that prevented using keyboard shortcuts when the DropDownDiv is open. * chore: Remove obsolete comment. * Refactor: Remove unreachable null check. * chore: Add tests for handling Escape to dismiss the Widget/DropDownDivs. * chore: Satisfy the linter. * fix: Fix post-merge test failure. --- core/common.ts | 27 +++++++++++++++++++++++++ core/dropdowndiv.ts | 15 ++++++++++++++ core/inject.ts | 36 ++------------------------------- core/widgetdiv.ts | 17 ++++++++++++---- tests/mocha/dropdowndiv_test.js | 33 ++++++++++++++++++++++++++++++ tests/mocha/widget_div_test.js | 23 +++++++++++++++++++++ 6 files changed, 113 insertions(+), 38 deletions(-) diff --git a/core/common.ts b/core/common.ts index 1f7ba7e88df..a4b198ae490 100644 --- a/core/common.ts +++ b/core/common.ts @@ -8,11 +8,13 @@ import type {Block} from './block.js'; import {BlockDefinition, Blocks} from './blocks.js'; +import * as browserEvents from './browser_events.js'; import type {Connection} from './connection.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; import {ISelectable, isSelectable} from './interfaces/i_selectable.js'; +import {ShortcutRegistry} from './shortcut_registry.js'; import type {Workspace} from './workspace.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -310,4 +312,29 @@ export function defineBlocks(blocks: {[key: string]: BlockDefinition}) { } } +/** + * Handle a key-down on SVG drawing surface. Does nothing if the main workspace + * is not visible. + * + * @internal + * @param e Key down event. + */ +export function globalShortcutHandler(e: KeyboardEvent) { + const mainWorkspace = getMainWorkspace() as WorkspaceSvg; + if (!mainWorkspace) { + return; + } + + if ( + browserEvents.isTargetInput(e) || + (mainWorkspace.rendered && !mainWorkspace.isVisible()) + ) { + // When focused on an HTML text input widget, don't trap any keys. + // Ignore keypresses on rendered workspaces that have been explicitly + // hidden. + return; + } + ShortcutRegistry.registry.onKeyDown(mainWorkspace, e); +} + export const TEST_ONLY = {defineBlocksWithJsonArrayInternal}; diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index c7b0da7116e..ceab467a895 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -13,6 +13,7 @@ // Former goog.module ID: Blockly.dropDownDiv import type {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; import * as common from './common.js'; import type {Field} from './field.js'; import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js'; @@ -86,6 +87,9 @@ let positionToField: boolean | null = null; /** Callback to FocusManager to return ephemeral focus when the div closes. */ let returnEphemeralFocus: ReturnEphemeralFocus | null = null; +/** Identifier for shortcut keydown listener used to unbind it. */ +let keydownListener: browserEvents.Data | null = null; + /** * Dropdown bounds info object used to encapsulate sizing information about a * bounding element (bounding box and width/height). @@ -130,6 +134,13 @@ export function createDom() { content.className = 'blocklyDropDownContent'; div.appendChild(content); + keydownListener = browserEvents.conditionalBind( + content, + 'keydown', + null, + common.globalShortcutHandler, + ); + arrow = document.createElement('div'); arrow.className = 'blocklyDropDownArrow'; div.appendChild(arrow); @@ -168,6 +179,10 @@ export function getContentDiv(): HTMLDivElement { /** Clear the content of the drop-down. */ export function clearContent() { + if (keydownListener) { + browserEvents.unbind(keydownListener); + keydownListener = null; + } div.remove(); createDom(); } diff --git a/core/inject.ts b/core/inject.ts index 34d9c1795f8..4217c515119 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -15,7 +15,6 @@ import * as dropDownDiv from './dropdowndiv.js'; import {Grid} from './grid.js'; import {Options} from './options.js'; import {ScrollbarPair} from './scrollbar_pair.js'; -import {ShortcutRegistry} from './shortcut_registry.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; import * as dom from './utils/dom.js'; @@ -72,17 +71,12 @@ export function inject( common.setMainWorkspace(workspace); }); - browserEvents.conditionalBind(subContainer, 'keydown', null, onKeyDown); browserEvents.conditionalBind( - dropDownDiv.getContentDiv(), + subContainer, 'keydown', null, - onKeyDown, + common.globalShortcutHandler, ); - const widgetContainer = WidgetDiv.getDiv(); - if (widgetContainer) { - browserEvents.conditionalBind(widgetContainer, 'keydown', null, onKeyDown); - } return workspace; } @@ -292,32 +286,6 @@ function init(mainWorkspace: WorkspaceSvg) { } } -/** - * Handle a key-down on SVG drawing surface. Does nothing if the main workspace - * is not visible. - * - * @param e Key down event. - */ -// TODO (https://github.com/google/blockly/issues/1998) handle cases where there -// are multiple workspaces and non-main workspaces are able to accept input. -function onKeyDown(e: KeyboardEvent) { - const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg; - if (!mainWorkspace) { - return; - } - - if ( - browserEvents.isTargetInput(e) || - (mainWorkspace.rendered && !mainWorkspace.isVisible()) - ) { - // When focused on an HTML text input widget, don't trap any keys. - // Ignore keypresses on rendered workspaces that have been explicitly - // hidden. - return; - } - ShortcutRegistry.registry.onKeyDown(mainWorkspace, e); -} - /** * Whether event handlers have been bound. Document event handlers will only * be bound once, even if Blockly is destroyed and reinjected. diff --git a/core/widgetdiv.ts b/core/widgetdiv.ts index 83e2384f510..d07f7fb502b 100644 --- a/core/widgetdiv.ts +++ b/core/widgetdiv.ts @@ -6,6 +6,7 @@ // Former goog.module ID: Blockly.WidgetDiv +import * as browserEvents from './browser_events.js'; import * as common from './common.js'; import {Field} from './field.js'; import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js'; @@ -66,15 +67,23 @@ export function testOnly_setDiv(newDiv: HTMLDivElement | null) { export function createDom() { const container = common.getParentContainer() || document.body; - if (document.querySelector('.' + containerClassName)) { - containerDiv = document.querySelector('.' + containerClassName); + const existingContainer = document.querySelector('div.' + containerClassName); + if (existingContainer) { + containerDiv = existingContainer as HTMLDivElement; } else { - containerDiv = document.createElement('div') as HTMLDivElement; + containerDiv = document.createElement('div'); containerDiv.className = containerClassName; containerDiv.tabIndex = -1; } - container.appendChild(containerDiv!); + browserEvents.conditionalBind( + containerDiv, + 'keydown', + null, + common.globalShortcutHandler, + ); + + container.appendChild(containerDiv); } /** diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index 451a726d6ee..fc792fbaf24 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -136,6 +136,39 @@ suite('DropDownDiv', function () { }); }); + suite('Keyboard Shortcuts', function () { + setup(function () { + this.boundsStub = sinon + .stub(Blockly.DropDownDiv.TEST_ONLY, 'getBoundsInfo') + .returns({ + left: 0, + right: 100, + top: 0, + bottom: 100, + width: 100, + height: 100, + }); + this.workspace = Blockly.inject('blocklyDiv', {}); + }); + teardown(function () { + this.boundsStub.restore(); + }); + test('Escape dismisses DropDownDiv', function () { + let hidden = false; + Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, () => { + hidden = true; + }); + assert.isFalse(hidden); + Blockly.DropDownDiv.getContentDiv().dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + keyCode: 27, // example values. + }), + ); + assert.isTrue(hidden); + }); + }); + suite('show()', function () { test('without bounds set throws error', function () { const block = this.setUpBlockWithField(); diff --git a/tests/mocha/widget_div_test.js b/tests/mocha/widget_div_test.js index 836a68282a0..61c94247110 100644 --- a/tests/mocha/widget_div_test.js +++ b/tests/mocha/widget_div_test.js @@ -287,6 +287,29 @@ suite('WidgetDiv', function () { }); }); + suite('Keyboard Shortcuts', function () { + test('Escape dismisses WidgetDiv', function () { + let hidden = false; + Blockly.WidgetDiv.show( + this, + false, + () => { + hidden = true; + }, + this.workspace, + false, + ); + assert.isFalse(hidden); + Blockly.WidgetDiv.getDiv().dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + keyCode: 27, // example values. + }), + ); + assert.isTrue(hidden); + }); + }); + suite('show()', function () { test('shows nowhere', function () { const block = this.setUpBlockWithField(); From edf344c542cec18d93af55026e151dd5360fcbe0 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Wed, 28 May 2025 00:43:27 +0100 Subject: [PATCH 21/32] fix: Tweak outline CSS for Safari/Firefox (#9100) Without this Safari (desktop) gets an outline still which tears as you drag. In the keyboard nav demo an outline was visible before this change in both Firefox and Safari. Fixes #9099 --- core/css.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/css.ts b/core/css.ts index 6b5e19a585b..4f4a4daaf90 100644 --- a/core/css.ts +++ b/core/css.ts @@ -505,6 +505,6 @@ input[type=number] { .blocklyIconGroup, .blocklyTextarea ) { - outline-width: 0px; + outline: none; } `; From d5a4522dd2817af4c5d3ce3b49564b9060d38ce6 Mon Sep 17 00:00:00 2001 From: RoboErikG Date: Wed, 28 May 2025 08:16:54 -0700 Subject: [PATCH 22/32] fix: Skip invisible inputs in the field navigation policy (#9092) * Skip over hidden inputs when navigating from a field * Add tests and fix implementation --- core/keyboard_nav/field_navigation_policy.ts | 23 +++++++++++--------- tests/mocha/navigation_test.js | 14 ++++++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/core/keyboard_nav/field_navigation_policy.ts b/core/keyboard_nav/field_navigation_policy.ts index 02d31cc355f..a1fa357233e 100644 --- a/core/keyboard_nav/field_navigation_policy.ts +++ b/core/keyboard_nav/field_navigation_policy.ts @@ -48,12 +48,14 @@ export class FieldNavigationPolicy implements INavigationPolicy> { let fieldIdx = input.fieldRow.indexOf(current) + 1; for (let i = curIdx; i < block.inputList.length; i++) { const newInput = block.inputList[i]; - const fieldRow = newInput.fieldRow; - if (fieldIdx < fieldRow.length) return fieldRow[fieldIdx]; - fieldIdx = 0; - if (newInput.connection?.targetBlock()) { - return newInput.connection.targetBlock() as BlockSvg; + if (newInput.isVisible()) { + const fieldRow = newInput.fieldRow; + if (fieldIdx < fieldRow.length) return fieldRow[fieldIdx]; + if (newInput.connection?.targetBlock()) { + return newInput.connection.targetBlock() as BlockSvg; + } } + fieldIdx = 0; } return null; } @@ -73,12 +75,13 @@ export class FieldNavigationPolicy implements INavigationPolicy> { let fieldIdx = parentInput.fieldRow.indexOf(current) - 1; for (let i = curIdx; i >= 0; i--) { const input = block.inputList[i]; - if (input.connection?.targetBlock() && input !== parentInput) { - return input.connection.targetBlock() as BlockSvg; + if (input.isVisible()) { + if (input.connection?.targetBlock() && input !== parentInput) { + return input.connection.targetBlock() as BlockSvg; + } + const fieldRow = input.fieldRow; + if (fieldIdx > -1) return fieldRow[fieldIdx]; } - const fieldRow = input.fieldRow; - if (fieldIdx > -1) return fieldRow[fieldIdx]; - // Reset the fieldIdx to the length of the field row of the previous // input. if (i - 1 >= 0) { diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index 3f98d4ca57c..14a9c8f631a 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -534,6 +534,13 @@ suite('Navigation', function () { const nextNode = this.navigator.getNextSibling(this.blocks.buttonBlock); assert.equal(nextNode.id, this.blocks.buttonNext.id); }); + test('fromFieldSkipsHiddenInputs', function () { + this.blocks.buttonBlock.inputList[2].setVisible(false); + const fieldStart = this.blocks.buttonBlock.getField('BUTTON2'); + const fieldEnd = this.blocks.buttonBlock.getField('BUTTON3'); + const nextNode = this.navigator.getNextSibling(fieldStart); + assert.equal(nextNode.name, fieldEnd.name); + }); }); suite('Previous', function () { @@ -669,6 +676,13 @@ suite('Navigation', function () { ); assert.equal(prevNode.id, this.blocks.buttonBlock.id); }); + test('fromFieldSkipsHiddenInputs', function () { + this.blocks.buttonBlock.inputList[2].setVisible(false); + const fieldStart = this.blocks.buttonBlock.getField('BUTTON3'); + const fieldEnd = this.blocks.buttonBlock.getField('BUTTON2'); + const nextNode = this.navigator.getPreviousSibling(fieldStart); + assert.equal(nextNode.name, fieldEnd.name); + }); }); suite('In', function () { From b0b685a739e7d0d3d3711767d94053724fa09348 Mon Sep 17 00:00:00 2001 From: Christopher Allen Date: Wed, 28 May 2025 17:16:02 +0100 Subject: [PATCH 23/32] refactor(shortcuts): Factor copy-eligibility out of cut/copy `preconditionFn` (#9102) * refactor(shortcuts): Rename import isDeletable -> isIDeletable etc. Some of the existing code is confusing to read because e.g. isDeletable doesn't check if an item .isDeletable(), but only whether it is an IDeletable. By renaming these imports the shortcut precondition functions are easier to understand, and allows a subsequent to commit to add an isCopyable function that actually checks copyability. * refactor(shortcuts): Introduce isCopyable Create a function, isCopyable, that encapsulates the criteria we currently use to determine whether an item can be copied. This facilitate future modification of the copyability criteria (but is not intended to modify them at all at the present time). * chore(shortcuts): Add TODO re: copying shadow blocks --- core/shortcut_items.ts | 73 +++++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 88d4228f934..c175637c6c4 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -10,9 +10,17 @@ import {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; import * as eventUtils from './events/utils.js'; import {Gesture} from './gesture.js'; -import {ICopyData, isCopyable} from './interfaces/i_copyable.js'; -import {isDeletable} from './interfaces/i_deletable.js'; -import {isDraggable} from './interfaces/i_draggable.js'; +import { + ICopyable, + ICopyData, + isCopyable as isICopyable, +} from './interfaces/i_copyable.js'; +import { + IDeletable, + isDeletable as isIDeletable, +} from './interfaces/i_deletable.js'; +import {IDraggable, isDraggable} from './interfaces/i_draggable.js'; +import {IFocusableNode} from './interfaces/i_focusable_node.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; import {Coordinate} from './utils/coordinate.js'; import {KeyCodes} from './utils/keycodes.js'; @@ -62,7 +70,7 @@ export function registerDelete() { return ( !workspace.isReadOnly() && focused != null && - isDeletable(focused) && + isIDeletable(focused) && focused.isDeletable() && !Gesture.inProgress() ); @@ -76,7 +84,7 @@ export function registerDelete() { const focused = scope.focusedNode; if (focused instanceof BlockSvg) { focused.checkAndDelete(); - } else if (isDeletable(focused) && focused.isDeletable()) { + } else if (isIDeletable(focused) && focused.isDeletable()) { eventUtils.setGroup(true); focused.dispose(); eventUtils.setGroup(false); @@ -92,6 +100,39 @@ let copyData: ICopyData | null = null; let copyWorkspace: WorkspaceSvg | null = null; let copyCoords: Coordinate | null = null; +/** + * Determine if a focusable node can be copied using cut or copy. + * + * Unfortunately the ICopyable interface doesn't include an isCopyable + * method, so we must use some other criteria to make the decision. + * Specifically, + * + * - It must be an ICopyable. + * - So that a pasted copy can be manipluated and/or disposed of, it + * must be both an IDraggable and an IDeletable. + * - Additionally, both .isMovable() and .isDeletable() must return + * true (i.e., can currently be moved and deleted). + * + * TODO(#9098): Revise these criteria. The latter criteria prevents + * shadow blocks from being copied; additionally, there are likely to + * be other circumstances were it is desirable to allow movable / + * copyable copies of a currently-unmovable / -copyable block to be + * made. + * + * @param focused The focused object. + */ +function isCopyable( + focused: IFocusableNode, +): focused is ICopyable & IDeletable & IDraggable { + return ( + isICopyable(focused) && + isIDeletable(focused) && + focused.isDeletable() && + isDraggable(focused) && + focused.isMovable() + ); +} + /** * Keyboard shortcut to copy a block on ctrl+c, cmd+c, or alt+c. */ @@ -110,11 +151,7 @@ export function registerCopy() { return ( !workspace.isReadOnly() && !Gesture.inProgress() && - focused != null && - isDeletable(focused) && - focused.isDeletable() && - isDraggable(focused) && - focused.isMovable() && + !!focused && isCopyable(focused) ); }, @@ -124,7 +161,7 @@ export function registerCopy() { e.preventDefault(); workspace.hideChaff(); const focused = scope.focusedNode; - if (!focused || !isCopyable(focused)) return false; + if (!focused || !isICopyable(focused)) return false; copyData = focused.toCopyData(); copyWorkspace = focused.workspace instanceof WorkspaceSvg @@ -158,13 +195,11 @@ export function registerCut() { return ( !workspace.isReadOnly() && !Gesture.inProgress() && - focused != null && - isDeletable(focused) && - focused.isDeletable() && - isDraggable(focused) && - focused.isMovable() && + !!focused && isCopyable(focused) && - !focused.workspace.isFlyout + // Extra criteria for cut (not just copy): + !focused.workspace.isFlyout && + focused.isDeletable() ); }, callback(workspace, e, shortcut, scope) { @@ -177,9 +212,9 @@ export function registerCut() { focused.checkAndDelete(); return true; } else if ( - isDeletable(focused) && + isIDeletable(focused) && focused.isDeletable() && - isCopyable(focused) + isICopyable(focused) ) { copyData = focused.toCopyData(); copyWorkspace = workspace; From 38df7c87765acfeef8efd208059649f1af339cb7 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 28 May 2025 20:43:16 -0700 Subject: [PATCH 24/32] feat: Allow visiting empty input connections. (#9104) * feat: Update navigation policies to allow visiting empty input connections. * fix: Fix tests. * chore: Add JSDoc. * fix: Add missing import. * fix: Fix JSDoc. * chore: Remove double comments. --- core/keyboard_nav/block_navigation_policy.ts | 180 ++++++++++-------- .../connection_navigation_policy.ts | 42 +--- core/keyboard_nav/field_navigation_policy.ts | 44 +---- core/keyboard_nav/icon_navigation_policy.ts | 26 +-- tests/mocha/cursor_test.js | 4 +- tests/mocha/navigation_test.js | 78 ++++---- 6 files changed, 150 insertions(+), 224 deletions(-) diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index af7eadc09d7..570b06fe392 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -5,9 +5,12 @@ */ 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 {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../rendered_connection.js'; import {WorkspaceSvg} from '../workspace_svg.js'; /** @@ -21,21 +24,8 @@ export class BlockNavigationPolicy implements INavigationPolicy { * @returns The first field or input of the given block, if any. */ getFirstChild(current: BlockSvg): IFocusableNode | null { - const icons = current.getIcons(); - if (icons.length) return icons[0]; - - for (const input of current.inputList) { - if (!input.isVisible()) { - continue; - } - for (const field of input.fieldRow) { - return field; - } - if (input.connection?.targetBlock()) - return input.connection.targetBlock() as BlockSvg; - } - - return null; + const candidates = getBlockNavigationCandidates(current); + return candidates[0]; } /** @@ -66,36 +56,10 @@ export class BlockNavigationPolicy implements INavigationPolicy { getNextSibling(current: BlockSvg): IFocusableNode | null { if (current.nextConnection?.targetBlock()) { return current.nextConnection?.targetBlock(); - } - - const parent = this.getParent(current); - let navigatingCrossStacks = false; - let siblings: (BlockSvg | Field)[] = []; - if (parent instanceof BlockSvg) { - for (let i = 0, input; (input = parent.inputList[i]); i++) { - if (!input.isVisible()) { - continue; - } - siblings.push(...input.fieldRow); - const child = input.connection?.targetBlock(); - if (child) { - siblings.push(child as BlockSvg); - } - } - } else if (parent instanceof WorkspaceSvg) { - siblings = parent.getTopBlocks(true); - navigatingCrossStacks = true; - } else { - return null; - } - - const currentIndex = siblings.indexOf( - navigatingCrossStacks ? current.getRootBlock() : current, - ); - if (currentIndex >= 0 && currentIndex < siblings.length - 1) { - return siblings[currentIndex + 1]; - } else if (currentIndex === siblings.length - 1 && navigatingCrossStacks) { - return siblings[0]; + } else if (current.outputConnection?.targetBlock()) { + return navigateBlock(current, 1); + } else if (this.getParent(current) instanceof WorkspaceSvg) { + return navigateStacks(current, 1); } return null; @@ -111,44 +75,13 @@ export class BlockNavigationPolicy implements INavigationPolicy { 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); } - const parent = this.getParent(current); - let navigatingCrossStacks = false; - let siblings: (BlockSvg | Field)[] = []; - if (parent instanceof BlockSvg) { - for (let i = 0, input; (input = parent.inputList[i]); i++) { - if (!input.isVisible()) { - continue; - } - siblings.push(...input.fieldRow); - const child = input.connection?.targetBlock(); - if (child) { - siblings.push(child as BlockSvg); - } - } - } else if (parent instanceof WorkspaceSvg) { - siblings = parent.getTopBlocks(true); - navigatingCrossStacks = true; - } else { - return null; - } - - const currentIndex = siblings.indexOf(current); - let result: IFocusableNode | null = null; - if (currentIndex >= 1) { - result = siblings[currentIndex - 1]; - } else if (currentIndex === 0 && navigatingCrossStacks) { - result = siblings[siblings.length - 1]; - } - - // If navigating to a previous stack, our previous sibling is the last - // block in it. - if (navigatingCrossStacks && result instanceof BlockSvg) { - return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; - } - - return result; + return null; } /** @@ -171,3 +104,88 @@ export class BlockNavigationPolicy implements INavigationPolicy { 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[] { + const candidates: IFocusableNode[] = block.getIcons(); + + for (const input of block.inputList) { + if (!input.isVisible()) continue; + candidates.push(...input.fieldRow); + if (input.connection?.targetBlock()) { + candidates.push(input.connection.targetBlock() as BlockSvg); + } 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 block's stack. + * + * @param current The block 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 block in the stack offset by `delta` relative to the + * current block's stack, or the last block in the stack offset by `delta` + * relative to the current block's stack when navigating backwards. + */ +export function navigateStacks(current: BlockSvg, delta: number) { + const stacks = current.workspace.getTopBlocks(true); + const currentIndex = stacks.indexOf(current.getRootBlock()); + const targetIndex = currentIndex + delta; + let result: BlockSvg | 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 stack, our previous sibling is the last + // block in it. + if (delta < 0 && result) { + 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.getSourceBlock(); + if (!(block instanceof BlockSvg)) return 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/core/keyboard_nav/connection_navigation_policy.ts b/core/keyboard_nav/connection_navigation_policy.ts index 9c3eafc56b0..bf685d0635c 100644 --- a/core/keyboard_nav/connection_navigation_policy.ts +++ b/core/keyboard_nav/connection_navigation_policy.ts @@ -9,6 +9,7 @@ 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. @@ -37,17 +38,7 @@ export class ConnectionNavigationPolicy * @returns The given connection's parent connection or block. */ getParent(current: RenderedConnection): IFocusableNode | null { - if (current.type === ConnectionType.OUTPUT_VALUE) { - return current.targetConnection ?? current.getSourceBlock(); - } else if (current.getParentInput()) { - return current.getSourceBlock(); - } - - const topBlock = current.getSourceBlock().getTopStackBlock(); - return ( - (this.getParentConnection(topBlock)?.targetConnection?.getParentInput() - ?.connection as RenderedConnection) ?? topBlock - ); + return current.getSourceBlock(); } /** @@ -58,19 +49,7 @@ export class ConnectionNavigationPolicy */ getNextSibling(current: RenderedConnection): IFocusableNode | null { if (current.getParentInput()) { - const parentInput = current.getParentInput(); - const block = parentInput?.getSourceBlock(); - if (!block || !parentInput) return null; - - const curIdx = block.inputList.indexOf(parentInput); - for (let i = curIdx + 1; i < block.inputList.length; i++) { - const input = block.inputList[i]; - const fieldRow = input.fieldRow; - if (fieldRow.length) return fieldRow[0]; - if (input.connection) return input.connection as RenderedConnection; - } - - return null; + 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 @@ -103,20 +82,7 @@ export class ConnectionNavigationPolicy */ getPreviousSibling(current: RenderedConnection): IFocusableNode | null { if (current.getParentInput()) { - const parentInput = current.getParentInput(); - const block = parentInput?.getSourceBlock(); - if (!block || !parentInput) return null; - - const curIdx = block.inputList.indexOf(parentInput); - for (let i = curIdx; i >= 0; i--) { - const input = block.inputList[i]; - if (input.connection && input !== parentInput) { - return input.connection as RenderedConnection; - } - const fieldRow = input.fieldRow; - if (fieldRow.length) return fieldRow[fieldRow.length - 1]; - } - return null; + return navigateBlock(current, -1); } else if ( current.type === ConnectionType.PREVIOUS_STATEMENT || current.type === ConnectionType.OUTPUT_VALUE diff --git a/core/keyboard_nav/field_navigation_policy.ts b/core/keyboard_nav/field_navigation_policy.ts index a1fa357233e..f9df406c22c 100644 --- a/core/keyboard_nav/field_navigation_policy.ts +++ b/core/keyboard_nav/field_navigation_policy.ts @@ -8,6 +8,7 @@ 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'; /** * Set of rules controlling keyboard navigation from a field. @@ -40,24 +41,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { * @returns The next field or input in the given field's block. */ getNextSibling(current: Field): IFocusableNode | null { - const input = current.getParentInput(); - const block = current.getSourceBlock(); - if (!block) return null; - - const curIdx = block.inputList.indexOf(input); - let fieldIdx = input.fieldRow.indexOf(current) + 1; - for (let i = curIdx; i < block.inputList.length; i++) { - const newInput = block.inputList[i]; - if (newInput.isVisible()) { - const fieldRow = newInput.fieldRow; - if (fieldIdx < fieldRow.length) return fieldRow[fieldIdx]; - if (newInput.connection?.targetBlock()) { - return newInput.connection.targetBlock() as BlockSvg; - } - } - fieldIdx = 0; - } - return null; + return navigateBlock(current, 1); } /** @@ -67,29 +51,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { * @returns The preceding field or input in the given field's block. */ getPreviousSibling(current: Field): IFocusableNode | null { - const parentInput = current.getParentInput(); - const block = current.getSourceBlock(); - if (!block) return null; - - const curIdx = block.inputList.indexOf(parentInput); - let fieldIdx = parentInput.fieldRow.indexOf(current) - 1; - for (let i = curIdx; i >= 0; i--) { - const input = block.inputList[i]; - if (input.isVisible()) { - if (input.connection?.targetBlock() && input !== parentInput) { - return input.connection.targetBlock() as BlockSvg; - } - const fieldRow = input.fieldRow; - if (fieldIdx > -1) return fieldRow[fieldIdx]; - } - // Reset the fieldIdx to the length of the field row of the previous - // input. - if (i - 1 >= 0) { - fieldIdx = block.inputList[i - 1].fieldRow.length - 1; - } - } - - return block.getIcons().pop() ?? null; + return navigateBlock(current, -1); } /** diff --git a/core/keyboard_nav/icon_navigation_policy.ts b/core/keyboard_nav/icon_navigation_policy.ts index 19d5a9c4d13..96908cbbdf8 100644 --- a/core/keyboard_nav/icon_navigation_policy.ts +++ b/core/keyboard_nav/icon_navigation_policy.ts @@ -8,6 +8,7 @@ import {BlockSvg} from '../block_svg.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 {navigateBlock} from './block_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from an icon. @@ -40,21 +41,7 @@ export class IconNavigationPolicy implements INavigationPolicy { * @returns The next icon, field or input following this icon, if any. */ getNextSibling(current: Icon): IFocusableNode | null { - const block = current.getSourceBlock() as BlockSvg; - const icons = block.getIcons(); - const currentIndex = icons.indexOf(current); - if (currentIndex >= 0 && currentIndex + 1 < icons.length) { - return icons[currentIndex + 1]; - } - - for (const input of block.inputList) { - if (input.fieldRow.length) return input.fieldRow[0]; - - if (input.connection?.targetBlock()) - return input.connection.targetBlock() as BlockSvg; - } - - return null; + return navigateBlock(current, 1); } /** @@ -64,14 +51,7 @@ export class IconNavigationPolicy implements INavigationPolicy { * @returns The icon's previous icon, if any. */ getPreviousSibling(current: Icon): IFocusableNode | null { - const block = current.getSourceBlock() as BlockSvg; - const icons = block.getIcons(); - const currentIndex = icons.indexOf(current); - if (currentIndex >= 1) { - return icons[currentIndex - 1]; - } - - return null; + return navigateBlock(current, -1); } /** diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index aa4f5618495..1d283f331a6 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -246,7 +246,7 @@ suite('Cursor', function () { }); test('getLastNode', function () { const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); + assert.equal(node, this.blockA.inputList[0].connection); }); }); suite('one c-hat block', function () { @@ -340,7 +340,7 @@ suite('Cursor', function () { test('getLastNode', function () { const node = this.cursor.getLastNode(); const blockB = this.workspace.getBlockById('B'); - assert.equal(node, blockB); + assert.equal(node, blockB.inputList[0].connection); }); }); diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index 14a9c8f631a..5bed2aaab8c 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -72,6 +72,20 @@ suite('Navigation', function () { 'tooltip': '', 'helpUrl': '', }, + { + 'type': 'double_value_input', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'input_value', + 'name': 'NAME1', + }, + { + 'type': 'input_value', + 'name': 'NAME2', + }, + ], + }, ]); this.workspace = Blockly.inject('blocklyDiv', {}); this.navigator = this.workspace.getNavigator(); @@ -80,6 +94,7 @@ suite('Navigation', function () { const statementInput3 = this.workspace.newBlock('input_statement'); const statementInput4 = this.workspace.newBlock('input_statement'); const fieldWithOutput = this.workspace.newBlock('field_input'); + const doubleValueInput = this.workspace.newBlock('double_value_input'); const valueInput = this.workspace.newBlock('value_input'); statementInput1.nextConnection.connect(statementInput2.previousConnection); @@ -97,6 +112,7 @@ suite('Navigation', function () { statementInput4: statementInput4, fieldWithOutput: fieldWithOutput, valueInput: valueInput, + doubleValueInput, }; }); teardown(function () { @@ -431,16 +447,9 @@ suite('Navigation', function () { assert.equal(nextNode, prevConnection); }); test('fromInputToInput', function () { - const input = this.blocks.statementInput1.inputList[0]; - const inputConnection = - this.blocks.statementInput1.inputList[1].connection; - const nextNode = this.navigator.getNextSibling(input.connection); - assert.equal(nextNode, inputConnection); - }); - test('fromInputToStatementInput', function () { - const input = this.blocks.fieldAndInputs2.inputList[1]; + const input = this.blocks.doubleValueInput.inputList[0]; const inputConnection = - this.blocks.fieldAndInputs2.inputList[2].connection; + this.blocks.doubleValueInput.inputList[1].connection; const nextNode = this.navigator.getNextSibling(input.connection); assert.equal(nextNode, inputConnection); }); @@ -575,6 +584,11 @@ suite('Navigation', function () { assert.equal(prevNode, this.blocks.statementInput1); }); test('fromInputToField', function () { + // Disconnect the block that was connected to the input we're testing, + // because we only navigate to/from empty input connections (if they're + // connected navigation targets the connected block, bypassing the + // connection). + this.blocks.fieldWithOutput.outputConnection.disconnect(); const input = this.blocks.statementInput1.inputList[0]; const prevNode = this.navigator.getPreviousSibling(input.connection); assert.equal(prevNode, input.fieldRow[1]); @@ -585,9 +599,9 @@ suite('Navigation', function () { assert.isNull(prevNode); }); test('fromInputToInput', function () { - const input = this.blocks.fieldAndInputs2.inputList[2]; + const input = this.blocks.doubleValueInput.inputList[1]; const inputConnection = - this.blocks.fieldAndInputs2.inputList[1].connection; + this.blocks.doubleValueInput.inputList[0].connection; const prevNode = this.navigator.getPreviousSibling(input.connection); assert.equal(prevNode, inputConnection); }); @@ -711,10 +725,10 @@ suite('Navigation', function () { const inNode = this.navigator.getFirstChild(input.connection); assert.equal(inNode, previousConnection); }); - test('fromBlockToField', function () { - const field = this.blocks.valueInput.getField('NAME'); + test('fromBlockToInput', function () { + const connection = this.blocks.valueInput.inputList[0].connection; const inNode = this.navigator.getFirstChild(this.blocks.valueInput); - assert.equal(inNode, field); + assert.equal(inNode, connection); }); test('fromBlockToField', function () { const inNode = this.navigator.getFirstChild( @@ -731,7 +745,10 @@ suite('Navigation', function () { const inNode = this.navigator.getFirstChild( this.blocks.dummyInputValue, ); - assert.equal(inNode, null); + assert.equal( + inNode, + this.blocks.dummyInputValue.inputList[1].connection, + ); }); test('fromOuputToNull', function () { const output = this.blocks.fieldWithOutput.outputConnection; @@ -787,13 +804,10 @@ suite('Navigation', function () { const outNode = this.navigator.getParent(input.connection); assert.equal(outNode, this.blocks.statementInput1); }); - test('fromOutputToInput', function () { + test('fromOutputToBlock', function () { const output = this.blocks.fieldWithOutput.outputConnection; const outNode = this.navigator.getParent(output); - assert.equal( - outNode, - this.blocks.statementInput1.inputList[0].connection, - ); + assert.equal(outNode, this.blocks.fieldWithOutput); }); test('fromOutputToBlock', function () { const output = this.blocks.fieldWithOutput2.outputConnection; @@ -805,43 +819,29 @@ suite('Navigation', function () { const outNode = this.navigator.getParent(field); assert.equal(outNode, this.blocks.statementInput1); }); - test('fromPreviousToInput', function () { - const previous = this.blocks.statementInput3.previousConnection; - const inputConnection = - this.blocks.statementInput2.inputList[1].connection; - const outNode = this.navigator.getParent(previous); - assert.equal(outNode, inputConnection); - }); test('fromPreviousToBlock', function () { const previous = this.blocks.statementInput2.previousConnection; const outNode = this.navigator.getParent(previous); - assert.equal(outNode, this.blocks.statementInput1); - }); - test('fromNextToInput', function () { - const next = this.blocks.statementInput3.nextConnection; - const inputConnection = - this.blocks.statementInput2.inputList[1].connection; - const outNode = this.navigator.getParent(next); - assert.equal(outNode, inputConnection); + assert.equal(outNode, this.blocks.statementInput2); }); test('fromNextToBlock', function () { const next = this.blocks.statementInput2.nextConnection; const outNode = this.navigator.getParent(next); - assert.equal(outNode, this.blocks.statementInput1); + assert.equal(outNode, this.blocks.statementInput2); }); test('fromNextToBlock_NoPreviousConnection', function () { const next = this.blocks.secondBlock.nextConnection; const outNode = this.navigator.getParent(next); - assert.equal(outNode, this.blocks.noPrevConnection); + assert.equal(outNode, this.blocks.secondBlock); }); /** * This is where there is a block with both an output connection and a * next connection attached to an input. */ - test('fromNextToInput_OutputAndPreviousConnection', function () { + test('fromNextToBlock_OutputAndPreviousConnection', function () { const next = this.blocks.outputNextBlock.nextConnection; const outNode = this.navigator.getParent(next); - assert.equal(outNode, this.blocks.secondBlock.inputList[0].connection); + assert.equal(outNode, this.blocks.outputNextBlock); }); test('fromBlockToWorkspace', function () { const outNode = this.navigator.getParent(this.blocks.statementInput2); From fd0c08e9504f25ed9bccec0532f25132cc5f96ac Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 29 May 2025 09:59:45 -0700 Subject: [PATCH 25/32] fix: Copy shortcuts before returning them (#9109) --- core/shortcut_registry.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/shortcut_registry.ts b/core/shortcut_registry.ts index 8a276c3d51c..f40149db816 100644 --- a/core/shortcut_registry.ts +++ b/core/shortcut_registry.ts @@ -278,7 +278,9 @@ export class ShortcutRegistry { * Undefined if no shortcuts exist. */ getShortcutNamesByKeyCode(keyCode: string): string[] | undefined { - return this.keyMap.get(keyCode) || []; + // Copy the list of shortcuts in case one of them unregisters itself + // in its callback. + return this.keyMap.get(keyCode)?.slice() || []; } /** From 3cbca8e4b61680378ad15acab6f9761b72451e48 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 29 May 2025 12:09:59 -0700 Subject: [PATCH 26/32] feat: Automatically manage focus tree tab indexes (#9079) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8965 Fixes #8978 Fixes #8970 Fixes https://github.com/google/blockly-keyboard-experimentation/issues/523 Fixes https://github.com/google/blockly-keyboard-experimentation/issues/547 Fixes part of #8910 ### Proposed Changes Fives groups of changes are included in this PR: 1. Support for automatic tab index management for focusable trees. 2. Support for automatic tab index management for focusable nodes. 3. Support for automatically hiding the flyout when back navigating from the toolbox. 4. A fix for `FocusManager` losing DOM syncing that was introduced in #9082. 5. Some cleanups for flyout and some tests for previous behavior changes to `FocusManager`. ### Reason for Changes Infrastructure changes reasoning: - Automatically managing tab indexes for both focusable trees and roots can largely reduce the difficulty of providing focusable nodes/trees and generally interacting with `FocusManager`. This facilitates a more automated navigation experience. - The fix for losing DOM syncing is possibly not reliable, but there are at least now tests to cover for it. This may be a case where a `try{} finally{}` could be warranted, but the code will stay as-is unless requested otherwise. `Flyout` changes: - `Flyout` no longer needs to be a focusable tree, but removing that would be an API breakage. Instead, it throws for most of the normal tree/node calls as it should no longer be used as such. Instead, its workspace has been made top-level tabbable (in addition to the main workspace) which solves the extra tab stop issues and general confusing inconsistencies between the flyout, toolbox, and workspace. - `Flyout` now correctly auto-selects the first block (#9103 notwithstanding). Technically it did before, however the extra `Flyout` tabstop before its workspace caused the inconsistency (since focusing the `Flyout` itself did not auto-select, only selecting its workspace did). Important caveats: - `getAttribute` is used in place of directly fetching `.tabIndex` since the latter can apparently default to `-1` (and possibly `0`) in cases when it's not actually set. This is a very surprising behavior that leads to incorrect test results. - Sometimes tab index still needs to be introduced (such as in cases where native DOM focus is needed, e.g. via `focus()` calls or clicking). This is demonstrated both by updates to `FocusManager`'s tests as well as toolbox's category and separator. This can be slightly tricky to miss as large parts of Blockly now depend on focus to represent their state, so clicking either needs to be managed by Blockly (with corresponding `focusNode` calls) or automatic (with a tab index defined for the element that can be clicked, or which has a child that can be clicked). Note that nearly all elements used for testing focus in the test `index.html` page have had their tab indexes removed to lean on `FocusManager`'s automatic tab management (though as mentioned above there is still some manual tab index management required for `focus()`-specific tests). ### Test Coverage New tests were added for all of the updated behaviors to `FocusManager`, including a new need to explicitly provide (and reset) tab indexes for all `focus()`-esque tests. This also includes adding new tests for some behaviors introduced in past PRs (a la #8910). Note that all of the new and affected conditionals in `FocusManager` have been verified as having at least 1 test that breaks when it's removed (inverted conditions weren't thoroughly tested, but it's expected that they should also be well covered now). Additional tests to cover the actual navigation flows will be added to the keyboard experimentation plugin repository as part of https://github.com/google/blockly-keyboard-experimentation/pull/557 (this PR needs to be merged first). For manual testing, I mainly verified keyboard navigation with some cursory mouse & click testing in the simple playground. @rachel-fenichel also performed more thorough mouse & click testing (that yielded an actual issue that was fixed--see discussion below). The core webdriver tests have been verified to have seemingly the same existing failures with and without these changes. All of the following new keyboard navigation plugin tests have been verified as failing without the fixes introduced in this branch (and passing with them): - `Tab navigating to flyout should auto-select first block` - `Keyboard nav to different toolbox category should auto-select first block` - `Keyboard nav to different toolbox category and block should select different block` - `Tab navigate away from toolbox restores focus to initial element` - `Tab navigate away from toolbox closes flyout` - `Tab navigate away from flyout to toolbox and away closes flyout` - `Tabbing to the workspace after selecting flyout block should close the flyout` - `Tabbing to the workspace after selecting flyout block via workspace toolbox shortcut should close the flyout` - `Tabbing back from workspace should reopen the flyout` - `Navigation position in workspace should be retained when tabbing to flyout and back` - `Clicking outside Blockly with focused toolbox closes the flyout` - `Clicking outside Blockly with focused flyout closes the flyout` - `Clicking on toolbox category focuses it and opens flyout` ### Documentation No documentation changes are needed beyond the code doc changes included in the PR. ### Additional Information An additional PR will be introduced for the keyboard experimentation plugin repository to add tests there (see test coverage above). This description will be updated with a link to that PR once it exists. --- core/bubbles/bubble.ts | 5 +- core/comments/rendered_workspace_comment.ts | 1 - core/field.ts | 1 - core/flyout_base.ts | 75 +- core/flyout_button.ts | 2 +- core/focus_manager.ts | 132 +++- core/icons/icon.ts | 1 - core/interfaces/i_focusable_node.ts | 18 +- core/renderers/common/path_object.ts | 3 +- core/toolbox/category.ts | 2 + core/toolbox/separator.ts | 2 + core/toolbox/toolbox.ts | 19 +- core/workspace_svg.ts | 10 +- tests/mocha/focus_manager_test.js | 734 +++++++++++++++++++- tests/mocha/index.html | 79 +-- 15 files changed, 925 insertions(+), 159 deletions(-) diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index 64060fe7888..20e730abb18 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -98,8 +98,8 @@ export abstract class Bubble implements IBubble, ISelectable { * when automatically positioning. * @param overriddenFocusableElement An optional replacement to the focusable * element that's represented by this bubble (as a focusable node). This - * element will have its ID and tabindex overwritten. If not provided, the - * focusable element of this node will default to the bubble's SVG root. + * element will have its ID overwritten. If not provided, the focusable + * element of this node will default to the bubble's SVG root. */ constructor( public readonly workspace: WorkspaceSvg, @@ -138,7 +138,6 @@ export abstract class Bubble implements IBubble, ISelectable { this.focusableElement = overriddenFocusableElement ?? this.svgRoot; this.focusableElement.setAttribute('id', this.id); - this.focusableElement.setAttribute('tabindex', '-1'); browserEvents.conditionalBind( this.background, diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index 00359b07011..3a3d57a441d 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -65,7 +65,6 @@ export class RenderedWorkspaceComment this.view.setEditable(this.isEditable()); this.view.getSvgRoot().setAttribute('data-id', this.id); this.view.getSvgRoot().setAttribute('id', this.id); - this.view.getSvgRoot().setAttribute('tabindex', '-1'); this.addModelUpdateBindings(); diff --git a/core/field.ts b/core/field.ts index f7e01527e5d..c4b6514785e 100644 --- a/core/field.ts +++ b/core/field.ts @@ -312,7 +312,6 @@ export abstract class Field const id = this.id_; if (!id) throw new Error('Expected ID to be defined prior to init.'); this.fieldGroup_ = dom.createSvgElement(Svg.G, { - 'tabindex': '-1', 'id': id, }); if (!this.isVisible()) { diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 9f94ec30905..492d3341762 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -22,7 +22,6 @@ 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 {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'; @@ -308,7 +307,6 @@ export abstract class Flyout // hide/show code will set up proper visibility and size later. this.svgGroup_ = dom.createSvgElement(tagName, { 'class': 'blocklyFlyout', - 'tabindex': '0', }); this.svgGroup_.style.display = 'none'; this.svgBackground_ = dom.createSvgElement( @@ -324,8 +322,6 @@ export abstract class Flyout .getThemeManager() .subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity'); - getFocusManager().registerTree(this); - return this.svgGroup_; } @@ -407,7 +403,6 @@ export abstract class Flyout if (this.svgGroup_) { dom.removeNode(this.svgGroup_); } - getFocusManager().unregisterTree(this); } /** @@ -971,15 +966,22 @@ export abstract class Flyout return null; } - /** See IFocusableNode.getFocusableElement. */ + /** + * See IFocusableNode.getFocusableElement. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getFocusableElement(): HTMLElement | SVGElement { - if (!this.svgGroup_) throw new Error('Flyout DOM is not yet created.'); - return this.svgGroup_; + throw new Error('Flyouts are not directly focusable.'); } - /** See IFocusableNode.getFocusableTree. */ + /** + * See IFocusableNode.getFocusableTree. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getFocusableTree(): IFocusableTree { - return this; + throw new Error('Flyouts are not directly focusable.'); } /** See IFocusableNode.onNodeFocus. */ @@ -990,31 +992,45 @@ export abstract class Flyout /** See IFocusableNode.canBeFocused. */ canBeFocused(): boolean { - return true; + return false; } - /** See IFocusableTree.getRootFocusableNode. */ + /** + * See IFocusableNode.getRootFocusableNode. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getRootFocusableNode(): IFocusableNode { - return this; + throw new Error('Flyouts are not directly focusable.'); } - /** See IFocusableTree.getRestoredFocusableNode. */ + /** + * See IFocusableNode.getRestoredFocusableNode. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getRestoredFocusableNode( _previousNode: IFocusableNode | null, ): IFocusableNode | null { - return null; + throw new Error('Flyouts are not directly focusable.'); } - /** See IFocusableTree.getNestedTrees. */ + /** + * See IFocusableNode.getNestedTrees. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getNestedTrees(): Array { - return [this.workspace_]; + throw new Error('Flyouts are not directly focusable.'); } - /** See IFocusableTree.lookUpFocusableNode. */ + /** + * See IFocusableNode.lookUpFocusableNode. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ lookUpFocusableNode(_id: string): IFocusableNode | null { - // No focusable node needs to be returned since the flyout's subtree is a - // workspace that will manage its own focusable state. - return null; + throw new Error('Flyouts are not directly focusable.'); } /** See IFocusableTree.onTreeFocus. */ @@ -1023,15 +1039,12 @@ export abstract class Flyout _previousTree: IFocusableTree | null, ): void {} - /** See IFocusableTree.onTreeBlur. */ - onTreeBlur(nextTree: IFocusableTree | null): void { - const toolbox = this.targetWorkspace.getToolbox(); - // If focus is moving to either the toolbox or the flyout's workspace, do - // not close the flyout. For anything else, do close it since the flyout is - // no longer focused. - if (toolbox && nextTree === toolbox) return; - if (nextTree === this.workspace_) return; - if (toolbox) toolbox.clearSelection(); - this.autoHide(false); + /** + * 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/core/flyout_button.ts b/core/flyout_button.ts index 823b57be765..c9afb8b0159 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -113,7 +113,7 @@ export class FlyoutButton this.id = idGenerator.getNextUniqueId(); this.svgGroup = dom.createSvgElement( Svg.G, - {'id': this.id, 'class': cssClass, 'tabindex': '-1'}, + {'id': this.id, 'class': cssClass}, this.workspace.getCanvas(), ); diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 198e1f0747d..01be4813f90 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -17,6 +17,24 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; */ export type ReturnEphemeralFocus = () => void; +/** + * Represents an IFocusableTree that has been registered for focus management in + * FocusManager. + */ +class TreeRegistration { + /** + * Constructs a new TreeRegistration. + * + * @param tree The tree being registered. + * @param rootShouldBeAutoTabbable Whether the tree should have automatic + * top-level tab management. + */ + constructor( + readonly tree: IFocusableTree, + readonly rootShouldBeAutoTabbable: boolean, + ) {} +} + /** * A per-page singleton that manages Blockly focus across one or more * IFocusableTrees, and bidirectionally synchronizes this focus with the DOM. @@ -58,7 +76,7 @@ export class FocusManager { private focusedNode: IFocusableNode | null = null; private previouslyFocusedNode: IFocusableNode | null = null; - private registeredTrees: Array = []; + private registeredTrees: Array = []; private currentlyHoldsEphemeralFocus: boolean = false; private lockFocusStateChanges: boolean = false; @@ -79,7 +97,8 @@ export class FocusManager { // If the target losing or gaining focus maps to any tree, then it // should be updated. Per the contract of findFocusableNodeFor only one // tree should claim the element, so the search can be exited early. - for (const tree of this.registeredTrees) { + for (const reg of this.registeredTrees) { + const tree = reg.tree; newNode = FocusableTreeTraverser.findFocusableNodeFor(element, tree); if (newNode) break; } @@ -132,13 +151,32 @@ export class FocusManager { * This function throws if the provided tree is already currently registered * in this manager. Use isRegistered to check in cases when it can't be * certain whether the tree has been registered. + * + * The tree's registration can be customized to configure automatic tab stops. + * This specifically provides capability for the user to be able to tab + * navigate to the root of the tree but only when the tree doesn't hold active + * focus. If this functionality is disabled then the tree's root will + * automatically be made focusable (but not tabbable) when it is first focused + * in the same way as any other focusable node. + * + * @param tree The IFocusableTree to register. + * @param rootShouldBeAutoTabbable Whether the root of this tree should be + * added as a top-level page tab stop when it doesn't hold active focus. */ - registerTree(tree: IFocusableTree): void { + registerTree( + tree: IFocusableTree, + rootShouldBeAutoTabbable: boolean = false, + ): void { this.ensureManagerIsUnlocked(); if (this.isRegistered(tree)) { throw Error(`Attempted to re-register already registered tree: ${tree}.`); } - this.registeredTrees.push(tree); + this.registeredTrees.push( + new TreeRegistration(tree, rootShouldBeAutoTabbable), + ); + if (rootShouldBeAutoTabbable) { + tree.getRootFocusableNode().getFocusableElement().tabIndex = 0; + } } /** @@ -147,7 +185,15 @@ export class FocusManager { * unregisterTree. */ isRegistered(tree: IFocusableTree): boolean { - return this.registeredTrees.findIndex((reg) => reg === tree) !== -1; + return !!this.lookUpRegistration(tree); + } + + /** + * Returns the TreeRegistration for the specified tree, or null if the tree is + * not currently registered. + */ + private lookUpRegistration(tree: IFocusableTree): TreeRegistration | null { + return this.registeredTrees.find((reg) => reg.tree === tree) ?? null; } /** @@ -158,13 +204,19 @@ export class FocusManager { * * This function throws if the provided tree is not currently registered in * this manager. + * + * This function will reset the tree's root element tabindex if the tree was + * registered with automatic tab management. */ unregisterTree(tree: IFocusableTree): void { this.ensureManagerIsUnlocked(); if (!this.isRegistered(tree)) { throw Error(`Attempted to unregister not registered tree: ${tree}.`); } - const treeIndex = this.registeredTrees.findIndex((reg) => reg === tree); + const treeIndex = this.registeredTrees.findIndex( + (reg) => reg.tree === tree, + ); + const registration = this.registeredTrees[treeIndex]; this.registeredTrees.splice(treeIndex, 1); const focusedNode = FocusableTreeTraverser.findFocusedNode(tree); @@ -174,6 +226,13 @@ export class FocusManager { this.updateFocusedNode(null); } this.removeHighlight(root); + + if (registration.rootShouldBeAutoTabbable) { + tree + .getRootFocusableNode() + .getFocusableElement() + .removeAttribute('tabindex'); + } } /** @@ -240,11 +299,15 @@ export class FocusManager { * canBeFocused() method returns false), it will be ignored and any existing * focus state will remain unchanged. * + * Note that this may update the specified node's element's tabindex to ensure + * that it can be properly read out by screenreaders while focused. + * * @param focusableNode The node that should receive active focus. */ focusNode(focusableNode: IFocusableNode): void { this.ensureManagerIsUnlocked(); - if (!this.currentlyHoldsEphemeralFocus) { + const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus; + if (mustRestoreUpdatingNode) { // Disable state syncing from DOM events since possible calls to focus() // below will loop a call back to focusNode(). this.isUpdatingFocusedNode = true; @@ -258,12 +321,21 @@ export class FocusManager { const prevFocusedElement = this.focusedNode?.getFocusableElement(); const hasDesyncedState = prevFocusedElement !== document.activeElement; if (this.focusedNode === focusableNode && !hasDesyncedState) { + if (mustRestoreUpdatingNode) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } return; // State is unchanged. } if (!focusableNode.canBeFocused()) { // This node can't be focused. console.warn("Trying to focus a node that can't be focused."); + + if (mustRestoreUpdatingNode) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } return; } @@ -312,7 +384,7 @@ export class FocusManager { this.activelyFocusNode(nodeToFocus, prevTree ?? null); } this.updateFocusedNode(nodeToFocus); - if (!this.currentlyHoldsEphemeralFocus) { + if (mustRestoreUpdatingNode) { // Reenable state syncing from DOM events. this.isUpdatingFocusedNode = false; } @@ -448,14 +520,38 @@ export class FocusManager { // node's focusable element (which *is* allowed to be invisible until the // node needs to be focused). this.lockFocusStateChanges = true; - if (node.getFocusableTree() !== prevTree) { - node.getFocusableTree().onTreeFocus(node, prevTree); + const tree = node.getFocusableTree(); + const elem = node.getFocusableElement(); + const nextTreeReg = this.lookUpRegistration(tree); + const treeIsTabManaged = nextTreeReg?.rootShouldBeAutoTabbable; + if (tree !== prevTree) { + tree.onTreeFocus(node, prevTree); + + if (treeIsTabManaged) { + // If this node's tree has its tab auto-managed, ensure that it's no + // longer tabbable now that it holds active focus. + tree.getRootFocusableNode().getFocusableElement().tabIndex = -1; + } } node.onNodeFocus(); this.lockFocusStateChanges = false; + // The tab index should be set in all cases where: + // - It doesn't overwrite an pre-set tab index for the node. + // - The node is part of a tree whose tab index is unmanaged. + // OR + // - The node is part of a managed tree but this isn't the root. Managed + // roots are ignored since they are always overwritten to have a tab index + // of -1 with active focus so that they cannot be tab navigated. + // + // Setting the tab index ensures that the node's focusable element can + // actually receive DOM focus. + if (!treeIsTabManaged || node !== tree.getRootFocusableNode()) { + if (!elem.hasAttribute('tabindex')) elem.tabIndex = -1; + } + this.setNodeToVisualActiveFocus(node); - node.getFocusableElement().focus(); + elem.focus(); } /** @@ -475,13 +571,21 @@ export class FocusManager { nextTree: IFocusableTree | null, ): void { this.lockFocusStateChanges = true; - if (node.getFocusableTree() !== nextTree) { - node.getFocusableTree().onTreeBlur(nextTree); + const tree = node.getFocusableTree(); + if (tree !== nextTree) { + tree.onTreeBlur(nextTree); + + const reg = this.lookUpRegistration(tree); + if (reg?.rootShouldBeAutoTabbable) { + // If this node's tree has its tab auto-managed, ensure that it's now + // tabbable since it no longer holds active focus. + tree.getRootFocusableNode().getFocusableElement().tabIndex = 0; + } } node.onNodeBlur(); this.lockFocusStateChanges = false; - if (node.getFocusableTree() !== nextTree) { + if (tree !== nextTree) { this.setNodeToVisualPassiveFocus(node); } } diff --git a/core/icons/icon.ts b/core/icons/icon.ts index 67547ee313e..8f8ff70fc32 100644 --- a/core/icons/icon.ts +++ b/core/icons/icon.ts @@ -59,7 +59,6 @@ export abstract class Icon implements IIcon { const svgBlock = this.sourceBlock as BlockSvg; this.svgRoot = dom.createSvgElement(Svg.G, { 'class': 'blocklyIconGroup', - 'tabindex': '-1', 'id': this.id, }); svgBlock.getSvgRoot().appendChild(this.svgRoot); diff --git a/core/interfaces/i_focusable_node.ts b/core/interfaces/i_focusable_node.ts index b21d7741a5c..00557168afa 100644 --- a/core/interfaces/i_focusable_node.ts +++ b/core/interfaces/i_focusable_node.ts @@ -19,13 +19,11 @@ export interface IFocusableNode { * - blocklyActiveFocus * - blocklyPassiveFocus * - * The returned element must also have a valid ID specified, and unique across - * the entire page. Failing to have a properly unique ID could result in - * trying to focus one node (such as via a mouse click) leading to another - * node with the same ID actually becoming focused by FocusManager. The - * returned element must also have a negative tabindex (since the focus - * manager itself will manage its tab index and a tab index must be present in - * order for the element to be focusable in the DOM). + * The returned element must also have a valid ID specified, and this ID + * should be unique across the entire page. Failing to have a properly unique + * ID could result in trying to focus one node (such as via a mouse click) + * leading to another node with the same ID actually becoming focused by + * FocusManager. * * The returned element must be visible if the node is ever focused via * FocusManager.focusNode() or FocusManager.focusTree(). It's allowed for an @@ -34,7 +32,11 @@ export interface IFocusableNode { * * It's expected the actual returned element will not change for the lifetime * of the node (that is, its properties can change but a new element should - * never be returned). + * never be returned). Also, the returned element will have its tabindex + * overwritten throughout the lifecycle of this node and FocusManager. + * + * If a node requires the ability to be focused directly without first being + * focused via FocusManager then it must set its own tab index. * * @returns The HTMLElement or SVGElement which can both receive focus and be * visually represented as actively or passively focused for this node. diff --git a/core/renderers/common/path_object.ts b/core/renderers/common/path_object.ts index 7efc6318a31..f6291b9f0fa 100644 --- a/core/renderers/common/path_object.ts +++ b/core/renderers/common/path_object.ts @@ -50,7 +50,7 @@ export class PathObject implements IPathObject { /** The primary path of the block. */ this.svgPath = dom.createSvgElement( Svg.PATH, - {'class': 'blocklyPath', 'tabindex': '-1'}, + {'class': 'blocklyPath'}, this.svgRoot, ); @@ -239,7 +239,6 @@ export class PathObject implements IPathObject { 'id': connection.id, 'class': 'blocklyHighlightedConnectionPath', 'style': 'display: none;', - 'tabindex': '-1', 'd': connectionPath, 'transform': transformation, }, diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index fc7d1aa03cf..7b0db7b3fcd 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -225,6 +225,8 @@ export class ToolboxCategory */ protected createContainer_(): HTMLDivElement { const container = document.createElement('div'); + // Ensure that the category has a tab index to ensure it receives focus when + // clicked (since clicking isn't managed by the toolbox). container.tabIndex = -1; container.id = this.getId(); const className = this.cssConfig_['container']; diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index 44ae358cf53..cd5ed245a04 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -54,6 +54,8 @@ export class ToolboxSeparator extends ToolboxItem { */ protected createDom_(): HTMLDivElement { const container = document.createElement('div'); + // Ensure that the separator has a tab index to ensure it receives focus + // when clicked (since clicking isn't managed by the toolbox). container.tabIndex = -1; container.id = this.getId(); const className = this.cssConfig_['container']; diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 0fbb231dc56..57e849ce264 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -22,7 +22,10 @@ import '../events/events_toolbox_item_select.js'; 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 { + isAutoHideable, + type IAutoHideable, +} from '../interfaces/i_autohideable.js'; import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js'; import {isDeletable} from '../interfaces/i_deletable.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; @@ -169,7 +172,7 @@ export class Toolbox ComponentManager.Capability.DRAG_TARGET, ], }); - getFocusManager().registerTree(this); + getFocusManager().registerTree(this, true); } /** @@ -200,7 +203,6 @@ export class Toolbox */ protected createContainer_(): HTMLDivElement { const toolboxContainer = document.createElement('div'); - toolboxContainer.tabIndex = 0; toolboxContainer.setAttribute('layout', this.isHorizontal() ? 'h' : 'v'); dom.addClass(toolboxContainer, 'blocklyToolbox'); toolboxContainer.setAttribute('dir', this.RTL ? 'RTL' : 'LTR'); @@ -1142,7 +1144,16 @@ export class Toolbox } /** See IFocusableTree.onTreeBlur. */ - onTreeBlur(_nextTree: IFocusableTree | null): void {} + onTreeBlur(nextTree: IFocusableTree | null): void { + // If navigating to anything other than the toolbox's flyout then clear the + // selection so that the toolbox's flyout can automatically close. + if (!nextTree || nextTree !== this.flyout?.getWorkspace()) { + this.clearSelection(); + if (this.flyout && isAutoHideable(this.flyout)) { + this.flyout.autoHide(false); + } + } + } } /** CSS for Toolbox. See css.js for use. */ diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 3e8731afd4b..5d5a40ccc5f 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -762,8 +762,6 @@ export class WorkspaceSvg */ this.svgGroup_ = dom.createSvgElement(Svg.G, { 'class': 'blocklyWorkspace', - // Only the top-level workspace should be tabbable. - 'tabindex': injectionDiv ? '0' : '-1', 'id': this.id, }); if (injectionDiv) { @@ -849,7 +847,8 @@ export class WorkspaceSvg isParentWorkspace ? this.getInjectionDiv() : undefined, ); - getFocusManager().registerTree(this); + // Only the top-level and flyout workspaces should be tabbable. + getFocusManager().registerTree(this, !!this.injectionDiv || this.isFlyout); return this.svgGroup_; } @@ -2807,13 +2806,12 @@ export class WorkspaceSvg /** See IFocusableTree.onTreeBlur. */ onTreeBlur(nextTree: IFocusableTree | null): void { // If the flyout loses focus, make sure to close it unless focus is being - // lost to a different element on the page. - if (nextTree && this.isFlyout && this.targetWorkspace) { + // lost to the toolbox. + if (this.isFlyout && this.targetWorkspace) { // Only hide the flyout if the flyout's workspace is losing focus and that // focus isn't returning to the flyout itself or the toolbox. const flyout = this.targetWorkspace.getFlyout(); const toolbox = this.targetWorkspace.getToolbox(); - if (flyout && nextTree === flyout) return; if (toolbox && nextTree === toolbox) return; if (toolbox) toolbox.clearSelection(); if (flyout && isAutoHideable(flyout)) flyout.autoHide(false); diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index cd89d1351b2..3a1fc98a7e5 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -80,81 +80,86 @@ suite('FocusManager', function () { const ACTIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME}`; const PASSIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME}`; - const createFocusableTree = function (rootElementId, nestedTrees) { - return new FocusableTreeImpl( - document.getElementById(rootElementId), - nestedTrees || [], - ); - }; - const createFocusableNode = function (tree, elementId) { - return tree.addNode(document.getElementById(elementId)); - }; - setup(function () { sharedTestSetup.call(this); - this.focusManager = getFocusManager(); - this.testFocusableTree1 = createFocusableTree('testFocusableTree1'); - this.testFocusableTree1Node1 = createFocusableNode( + this.allFocusableTrees = []; + this.allFocusableNodes = []; + this.createFocusableTree = function (rootElementId, nestedTrees) { + const tree = new FocusableTreeImpl( + document.getElementById(rootElementId), + nestedTrees || [], + ); + this.allFocusableTrees.push(tree); + return tree; + }; + this.createFocusableNode = function (tree, elementId) { + const node = tree.addNode(document.getElementById(elementId)); + this.allFocusableNodes.push(node); + return node; + }; + + this.testFocusableTree1 = this.createFocusableTree('testFocusableTree1'); + this.testFocusableTree1Node1 = this.createFocusableNode( this.testFocusableTree1, 'testFocusableTree1.node1', ); - this.testFocusableTree1Node1Child1 = createFocusableNode( + this.testFocusableTree1Node1Child1 = this.createFocusableNode( this.testFocusableTree1, 'testFocusableTree1.node1.child1', ); - this.testFocusableTree1Node2 = createFocusableNode( + this.testFocusableTree1Node2 = this.createFocusableNode( this.testFocusableTree1, 'testFocusableTree1.node2', ); - this.testFocusableNestedTree4 = createFocusableTree( + this.testFocusableNestedTree4 = this.createFocusableTree( 'testFocusableNestedTree4', ); - this.testFocusableNestedTree4Node1 = createFocusableNode( + this.testFocusableNestedTree4Node1 = this.createFocusableNode( this.testFocusableNestedTree4, 'testFocusableNestedTree4.node1', ); - this.testFocusableNestedTree5 = createFocusableTree( + this.testFocusableNestedTree5 = this.createFocusableTree( 'testFocusableNestedTree5', ); - this.testFocusableNestedTree5Node1 = createFocusableNode( + this.testFocusableNestedTree5Node1 = this.createFocusableNode( this.testFocusableNestedTree5, 'testFocusableNestedTree5.node1', ); - this.testFocusableTree2 = createFocusableTree('testFocusableTree2', [ + this.testFocusableTree2 = this.createFocusableTree('testFocusableTree2', [ this.testFocusableNestedTree4, this.testFocusableNestedTree5, ]); - this.testFocusableTree2Node1 = createFocusableNode( + this.testFocusableTree2Node1 = this.createFocusableNode( this.testFocusableTree2, 'testFocusableTree2.node1', ); - this.testFocusableGroup1 = createFocusableTree('testFocusableGroup1'); - this.testFocusableGroup1Node1 = createFocusableNode( + this.testFocusableGroup1 = this.createFocusableTree('testFocusableGroup1'); + this.testFocusableGroup1Node1 = this.createFocusableNode( this.testFocusableGroup1, 'testFocusableGroup1.node1', ); - this.testFocusableGroup1Node1Child1 = createFocusableNode( + this.testFocusableGroup1Node1Child1 = this.createFocusableNode( this.testFocusableGroup1, 'testFocusableGroup1.node1.child1', ); - this.testFocusableGroup1Node2 = createFocusableNode( + this.testFocusableGroup1Node2 = this.createFocusableNode( this.testFocusableGroup1, 'testFocusableGroup1.node2', ); - this.testFocusableNestedGroup4 = createFocusableTree( + this.testFocusableNestedGroup4 = this.createFocusableTree( 'testFocusableNestedGroup4', ); - this.testFocusableNestedGroup4Node1 = createFocusableNode( + this.testFocusableNestedGroup4Node1 = this.createFocusableNode( this.testFocusableNestedGroup4, 'testFocusableNestedGroup4.node1', ); - this.testFocusableGroup2 = createFocusableTree('testFocusableGroup2', [ + this.testFocusableGroup2 = this.createFocusableTree('testFocusableGroup2', [ this.testFocusableNestedGroup4, ]); - this.testFocusableGroup2Node1 = createFocusableNode( + this.testFocusableGroup2Node1 = this.createFocusableNode( this.testFocusableGroup2, 'testFocusableGroup2.node1', ); @@ -177,6 +182,19 @@ suite('FocusManager', function () { elem.classList.remove(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); } + // Ensure any set tab indexes are properly reset between tests. + for (const tree of this.allFocusableTrees) { + tree + .getRootFocusableNode() + .getFocusableElement() + .removeAttribute('tabindex'); + } + for (const node of this.allFocusableNodes) { + node.getFocusableElement().removeAttribute('tabindex'); + } + this.allFocusableTrees = []; + this.allFocusableNodes = []; + // Reset the current active element. document.body.focus(); }); @@ -230,6 +248,44 @@ suite('FocusManager', function () { // The second register should not fail since the tree was previously unregistered. }); + + test('for unmanaged tree does not overwrite tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.isNull(rootElem.getAttribute('tabindex')); + }); + + test('for unmanaged tree with custom tab index does not overwrite tab index', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = -1; + + this.focusManager.registerTree(this.testFocusableTree1, false); + + // The custom tab index shouldn't be overwritten for an unmanaged tree. + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('for managed tree overwrites root tab index to be tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); + + test('for managed tree with custom tab index overwrites root tab index to be tab navigable', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = -1; + + this.focusManager.registerTree(this.testFocusableTree1, true); + + // A custom tab index should be overwritten for a managed tree. + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); }); suite('unregisterTree()', function () { @@ -259,6 +315,41 @@ suite('FocusManager', function () { errorMsgRegex, ); }); + + test('for unmanaged tree with custom tab index does not change tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = -1; + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Unregistering an unmanaged tree shouldn't change its tab index. + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('for managed tree removes tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Unregistering a managed tree should remove its tab index. + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.isNull(rootElem.getAttribute('tabindex')); + }); + + test('for managed tree with custom tab index removes tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = -1; + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Unregistering a managed tree should remove its tab index. + assert.isNull(rootElem.getAttribute('tabindex')); + }); }); suite('isRegistered()', function () { @@ -330,6 +421,17 @@ suite('FocusManager', function () { assert.isNull(focusedNode); }); + + test('after focusing unfocusable node returns null', function () { + this.testFocusableTree1Node1.canBeFocused = () => false; + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + const focusedNode = this.focusManager.getFocusedNode(); + + // Unfocusable nodes should not be focused. + assert.isNull(focusedNode); + }); }); suite('focusTree()', function () { @@ -353,6 +455,15 @@ suite('FocusManager', function () { }); }); + test('unfocused node does not have a tab index by default', function () { + const elem = this.testFocusableTree1Node1.getFocusableElement(); + + // This is slightly testing the test setup, but it acts as a precondition sanity test for the + // other tab index tests below. Important: 'getAttribute' is used here since direct access to + // 'tabIndex' can default the value returned even when the tab index isn't set. + assert.isNull(elem.getAttribute('tabindex')); + }); + suite('focusNode()', function () { test('for not registered node throws', function () { const errorMsgRegex = /Attempted to focus unregistered node.+?/; @@ -504,6 +615,210 @@ suite('FocusManager', function () { assert.strictEqual(this.testFocusableTree1.onTreeBlur.callCount, 1); }); + + test('for same node twice calls onNodeFocus once', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeFocus'); + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Call focus for the same node a second time. + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Despite two calls to focus the node should only focus once. + assert.strictEqual(this.testFocusableTree1Node1.onNodeFocus.callCount, 1); + }); + + test('for unfocusable node does not call onNodeFocus', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeFocus'); + this.testFocusableTree1Node1.canBeFocused = () => false; + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Unfocusable nodes should not be focused, nor have their callbacks called. + assert.strictEqual(this.testFocusableTree1Node1.onNodeFocus.callCount, 0); + }); + + test('for unfocused node overwrites tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Focusing an element should overwrite its tab index. + const elem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(elem.getAttribute('tabindex'), '-1'); + }); + + test('for previously focused node keeps new tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + // The previously focused element should retain its tab index. + const elem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(elem.getAttribute('tabindex'), '-1'); + }); + + test('for node with custom tab index does not change tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1); + const elem = this.testFocusableTree1Node1.getFocusableElement(); + elem.tabIndex = 0; + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // If the node already has a tab index set then it should retain that index. + assert.strictEqual(elem.getAttribute('tabindex'), '0'); + }); + + suite('for unmanaged tree', function () { + test('focused root overwrites tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + + this.focusManager.focusNode(rootNode); + + // Focusing an unmanaged tree's root should overwrite its tab index. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused root with custom tab index does not change tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = 0; + + this.focusManager.focusNode(rootNode); + + // If the node already has a tab index set then it should retain that index. + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); + + test('focused node in a tree after unmanaged was focused should keep previous root unchanged', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + this.focusManager.registerTree(this.testFocusableTree2, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + // Focusing a different tree shouldn't change the root of the previous tree if it's unmanaged. + const rootElem = rootNode.getFocusableElement(); + assert.isNull(rootElem.getAttribute('tabindex')); + }); + + test('focused node in a tree after unmanaged was root focused should make previous root tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + this.focusManager.registerTree(this.testFocusableTree2, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + // The previous tree's root should be kept unchanged (since it was managed). + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + }); + + suite('for managed tree', function () { + test('for unfocused node in managed tree overwrites tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Focusing an element should overwrite its tab index. + const elem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(elem.getAttribute('tabindex'), '-1'); + }); + + test('for previously focused node in managed tree keeps new tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + // The previously focused element should retain its tab index. + const elem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(elem.getAttribute('tabindex'), '-1'); + }); + + test('focused root makes root non-tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + + this.focusManager.focusNode(rootNode); + + // Focusing the root in a managed tree should make it non-tab navigable. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused root with custom tab index should overwrite tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = 0; + + this.focusManager.focusNode(rootNode); + + // Custom tab indexes are overwritten for the root in a managed tree. + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused node tree root makes root non-tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Focusing a node of a managed tree should make the root non-tab navigable. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused node root with custom tab index should overwrite tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = 0; + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Custom tab indexes are overwritten for the root in a managed tree even when a tree's node + // is focused. + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused node in a tree after managed was focused should make previous root tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + this.focusManager.registerTree(this.testFocusableTree2, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + // Focusing a different tree shouldn't after a managed tree should make the managed tree tab + // navigable. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); + + test('focused node in a tree after managed was root focused should make previous root tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + this.focusManager.registerTree(this.testFocusableTree2, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + // Focusing a different tree shouldn't after a managed tree should make the managed tree tab + // navigable. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); + }); }); suite('getFocusManager()', function () { @@ -950,8 +1265,8 @@ suite('FocusManager', function () { nodeElem.textContent = 'Focusable node'; rootElem.appendChild(nodeElem); document.body.appendChild(rootElem); - const root = createFocusableTree('focusRoot'); - const node = createFocusableNode(root, 'focusNode'); + const root = this.createFocusableTree('focusRoot'); + const node = this.createFocusableNode(root, 'focusNode'); this.focusManager.registerTree(root); this.focusManager.focusNode(node); @@ -1424,6 +1739,7 @@ suite('FocusManager', function () { suite('getFocusedTree()', function () { test('registered root focus()ed no prev focus returns tree', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); @@ -1435,6 +1751,7 @@ suite('FocusManager', function () { test("registered node focus()ed no prev focus returns node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); @@ -1446,6 +1763,8 @@ suite('FocusManager', function () { test("registered subnode focus()ed no prev focus returns node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1.child1').tabIndex = + -1; document.getElementById('testFocusableTree1.node1.child1').focus(); @@ -1457,6 +1776,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus returns same tree', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree1.node2').focus(); @@ -1470,6 +1791,8 @@ suite('FocusManager', function () { test("registered node focus()ed after prev node focus diff tree returns new node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1483,6 +1806,8 @@ suite('FocusManager', function () { test("registered tree root focus()ed after prev node focus diff tree returns new node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2').focus(); @@ -1495,6 +1820,9 @@ suite('FocusManager', function () { test("non-registered node subelement focus()ed returns node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById( + 'testFocusableTree1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableTree1.node2.unregisteredChild1') @@ -1508,12 +1836,18 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3').focus(); assert.isNull(this.focusManager.getFocusedTree()); }); test('non-registered tree node focus()ed returns null', function () { + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); assert.isNull(this.focusManager.getFocusedTree()); @@ -1521,6 +1855,10 @@ suite('FocusManager', function () { test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnregisteredFocusableTree3.node1').focus(); @@ -1530,6 +1868,7 @@ suite('FocusManager', function () { test('unfocusable element focus()ed after registered node focused returns original tree', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -1542,6 +1881,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1551,6 +1891,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1561,6 +1902,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableTree1.node1').focus(); @@ -1573,6 +1916,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently focused returns new tree', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1588,6 +1933,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1603,6 +1951,7 @@ suite('FocusManager', function () { test('nested tree focusTree()ed with no prev focus returns nested tree', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4').tabIndex = -1; document.getElementById('testFocusableNestedTree4').focus(); @@ -1615,6 +1964,7 @@ suite('FocusManager', function () { test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -1627,6 +1977,8 @@ suite('FocusManager', function () { test('nested tree node focusNode()ed after parent focused returns nested tree', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -1640,6 +1992,7 @@ suite('FocusManager', function () { suite('getFocusedNode()', function () { test('registered root focus()ed no prev focus returns root node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); @@ -1651,6 +2004,7 @@ suite('FocusManager', function () { test('registered node focus()ed no prev focus returns node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); @@ -1662,6 +2016,8 @@ suite('FocusManager', function () { test('registered subnode focus()ed no prev focus returns subnode', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1.child1').tabIndex = + -1; document.getElementById('testFocusableTree1.node1.child1').focus(); @@ -1673,6 +2029,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus returns new node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree1.node2').focus(); @@ -1686,6 +2044,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree returns new node', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1699,6 +2059,8 @@ suite('FocusManager', function () { test('registered tree root focus()ed after prev node focus diff tree returns new root', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2').focus(); @@ -1711,6 +2073,9 @@ suite('FocusManager', function () { test('non-registered node subelement focus()ed returns nearest node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById( + 'testFocusableTree1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableTree1.node2.unregisteredChild1') @@ -1724,12 +2089,18 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3').focus(); assert.isNull(this.focusManager.getFocusedNode()); }); test('non-registered tree node focus()ed returns null', function () { + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); assert.isNull(this.focusManager.getFocusedNode()); @@ -1737,6 +2108,10 @@ suite('FocusManager', function () { test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnregisteredFocusableTree3.node1').focus(); @@ -1746,6 +2121,7 @@ suite('FocusManager', function () { test('unfocusable element focus()ed after registered node focused returns original node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -1758,6 +2134,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1767,6 +2144,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1777,6 +2155,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableTree1.node1').focus(); @@ -1789,6 +2169,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently focused returns new node', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1804,6 +2186,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1819,6 +2204,7 @@ suite('FocusManager', function () { test('nested tree focus()ed with no prev focus returns nested root', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4').tabIndex = -1; document.getElementById('testFocusableNestedTree4').focus(); @@ -1831,6 +2217,7 @@ suite('FocusManager', function () { test('nested tree node focus()ed with no prev focus returns focused node', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -1843,6 +2230,8 @@ suite('FocusManager', function () { test('nested tree node focus()ed after parent focused returns focused node', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -1863,9 +2252,10 @@ suite('FocusManager', function () { nodeElem.textContent = 'Focusable node'; rootElem.appendChild(nodeElem); document.body.appendChild(rootElem); - const root = createFocusableTree('focusRoot'); - const node = createFocusableNode(root, 'focusNode'); + const root = this.createFocusableTree('focusRoot'); + const node = this.createFocusableNode(root, 'focusNode'); this.focusManager.registerTree(root); + document.getElementById('focusNode').tabIndex = -1; document.getElementById('focusNode').focus(); node.getFocusableElement().remove(); @@ -1873,10 +2263,44 @@ suite('FocusManager', function () { assert.notStrictEqual(this.focusManager.getFocusedNode(), node); rootElem.remove(); // Cleanup. }); + + test('after focus() after trying to focusNode() an unfocusable node updates returns focus()ed node', function () { + this.testFocusableTree1Node1.canBeFocused = () => false; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + document.getElementById('testFocusableTree1.node2').focus(); + + // focus()ing a new node should overwrite a failed attempt to focusNode() an unfocusable + // node. This verifies that DOM focus syncing is properly reenabled by FocusManager. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node2, + ); + }); + + test('after focus() after trying to focusNode() the same node twice returns focus()ed node', function () { + document.getElementById('testFocusableTree1.node2').tabIndex = -1; + this.focusManager.registerTree(this.testFocusableTree1); + // Intentionally try to focus the same node twice. + this.focusManager.focusNode(this.testFocusableTree1Node1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + document.getElementById('testFocusableTree1.node2').focus(); + + // focus()ing a new node should overwrite a failed attempt to focusNode() the same node + // twice. This verifies that DOM focus syncing is properly reenabled by FocusManager. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node2, + ); + }); }); suite('CSS classes', function () { test('registered root focus()ed no prev focus returns root elem has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); @@ -1895,6 +2319,7 @@ suite('FocusManager', function () { test('registered node focus()ed no prev focus node elem has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); @@ -1911,6 +2336,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus same tree old node elem has no focus property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree1.node2').focus(); @@ -1928,6 +2355,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus same tree new node elem has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree1.node2').focus(); @@ -1946,6 +2375,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree old node elem has passive property', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1964,6 +2395,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree new node elem has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1982,6 +2415,8 @@ suite('FocusManager', function () { test('registered tree root focus()ed after prev node focus diff tree new root has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2').focus(); @@ -2001,6 +2436,9 @@ suite('FocusManager', function () { test('non-registered node subelement focus()ed nearest node has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById( + 'testFocusableTree1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableTree1.node2.unregisteredChild1') @@ -2019,10 +2457,11 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed has no focus', function () { + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3').focus(); assert.isNull(this.focusManager.getFocusedNode()); - const rootElem = document.getElementById( 'testUnregisteredFocusableTree3', ); @@ -2037,10 +2476,13 @@ suite('FocusManager', function () { }); test('non-registered tree node focus()ed has no focus', function () { + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); assert.isNull(this.focusManager.getFocusedNode()); - const nodeElem = document.getElementById( 'testUnregisteredFocusableTree3.node1', ); @@ -2056,6 +2498,7 @@ suite('FocusManager', function () { test('unfocsable element focus()ed after registered node focused original node has active focus', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -2086,6 +2529,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus removes focus', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -2106,6 +2550,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus removes focus', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -2125,6 +2570,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior removes focus from removed tree', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableTree1.node1').focus(); @@ -2157,6 +2604,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently removes focus from removed tree', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -2189,6 +2638,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering removes active indicator', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -2221,6 +2673,9 @@ suite('FocusManager', function () { test('focus() multiple nodes in same tree with switches ensure passive focus has gone', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -2243,6 +2698,9 @@ suite('FocusManager', function () { test('registered tree focus()ed other tree node passively focused tree node now has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -2276,6 +2734,9 @@ suite('FocusManager', function () { test('focus on root, node in diff tree, then node in first tree; root should have focus gone', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -2306,6 +2767,7 @@ suite('FocusManager', function () { test('nested tree focus()ed with no prev root has active focus', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4').tabIndex = -1; document.getElementById('testFocusableNestedTree4').focus(); @@ -2325,6 +2787,7 @@ suite('FocusManager', function () { test('nested tree node focus()ed with no prev focus node has active focus', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -2343,6 +2806,8 @@ suite('FocusManager', function () { test('nested tree node focus()ed after parent focused prev has passive node has active', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -3255,6 +3720,7 @@ suite('FocusManager', function () { suite('getFocusedTree()', function () { test('registered root focus()ed no prev focus returns tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); @@ -3266,6 +3732,7 @@ suite('FocusManager', function () { test("registered node focus()ed no prev focus returns node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); @@ -3277,6 +3744,8 @@ suite('FocusManager', function () { test("registered subnode focus()ed no prev focus returns node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1.child1').tabIndex = + -1; document.getElementById('testFocusableGroup1.node1.child1').focus(); @@ -3288,6 +3757,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus returns same tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup1.node2').focus(); @@ -3301,6 +3772,8 @@ suite('FocusManager', function () { test("registered node focus()ed after prev node focus diff tree returns new node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3314,6 +3787,8 @@ suite('FocusManager', function () { test("registered tree root focus()ed after prev node focus diff tree returns new node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2').focus(); @@ -3326,6 +3801,9 @@ suite('FocusManager', function () { test("non-registered node subelement focus()ed returns node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById( + 'testFocusableGroup1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableGroup1.node2.unregisteredChild1') @@ -3339,12 +3817,19 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableGroup3').tabIndex = + -1; + document.getElementById('testUnregisteredFocusableGroup3').focus(); assert.isNull(this.focusManager.getFocusedTree()); }); test('non-registered tree node focus()ed returns null', function () { + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; + document .getElementById('testUnregisteredFocusableGroup3.node1') .focus(); @@ -3354,6 +3839,10 @@ suite('FocusManager', function () { test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document @@ -3368,6 +3857,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3377,6 +3867,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3387,6 +3878,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableGroup1.node1').focus(); @@ -3399,6 +3892,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently focused returns new tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3414,6 +3909,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3429,6 +3927,7 @@ suite('FocusManager', function () { test('nested tree focusTree()ed with no prev focus returns nested tree', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4').tabIndex = -1; document.getElementById('testFocusableNestedGroup4').focus(); @@ -3441,6 +3940,8 @@ suite('FocusManager', function () { test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -3453,6 +3954,9 @@ suite('FocusManager', function () { test('nested tree node focusNode()ed after parent focused returns nested tree', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -3466,6 +3970,7 @@ suite('FocusManager', function () { suite('getFocusedNode()', function () { test('registered root focus()ed no prev focus returns root node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); @@ -3477,6 +3982,7 @@ suite('FocusManager', function () { test('registered node focus()ed no prev focus returns node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); @@ -3488,6 +3994,8 @@ suite('FocusManager', function () { test('registered subnode focus()ed no prev focus returns subnode', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1.child1').tabIndex = + -1; document.getElementById('testFocusableGroup1.node1.child1').focus(); @@ -3499,6 +4007,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus returns new node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup1.node2').focus(); @@ -3512,6 +4022,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree returns new node', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3525,6 +4037,8 @@ suite('FocusManager', function () { test('registered tree root focus()ed after prev node focus diff tree returns new root', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2').focus(); @@ -3537,6 +4051,9 @@ suite('FocusManager', function () { test('non-registered node subelement focus()ed returns nearest node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById( + 'testFocusableGroup1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableGroup1.node2.unregisteredChild1') @@ -3550,12 +4067,19 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableGroup3').tabIndex = + -1; + document.getElementById('testUnregisteredFocusableGroup3').focus(); assert.isNull(this.focusManager.getFocusedNode()); }); test('non-registered tree node focus()ed returns null', function () { + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; + document .getElementById('testUnregisteredFocusableGroup3.node1') .focus(); @@ -3565,6 +4089,10 @@ suite('FocusManager', function () { test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document @@ -3576,6 +4104,7 @@ suite('FocusManager', function () { test('unfocusable element focus()ed after registered node focused returns original node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -3588,6 +4117,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3597,6 +4127,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3607,6 +4138,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableGroup1.node1').focus(); @@ -3619,6 +4152,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently focused returns new node', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3634,6 +4169,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3649,6 +4187,7 @@ suite('FocusManager', function () { test('nested tree focus()ed with no prev focus returns nested root', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4').tabIndex = -1; document.getElementById('testFocusableNestedGroup4').focus(); @@ -3661,6 +4200,8 @@ suite('FocusManager', function () { test('nested tree node focus()ed with no prev focus returns focused node', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -3673,6 +4214,9 @@ suite('FocusManager', function () { test('nested tree node focus()ed after parent focused returns focused node', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -3682,10 +4226,44 @@ suite('FocusManager', function () { this.testFocusableNestedGroup4Node1, ); }); + + test('after focus() after trying to focusNode() an unfocusable node updates returns focus()ed node', function () { + this.testFocusableGroup1Node1.canBeFocused = () => false; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + document.getElementById('testFocusableGroup1.node2').focus(); + + // focus()ing a new node should overwrite a failed attempt to focusNode() an unfocusable + // node. This verifies that DOM focus syncing is properly reenabled by FocusManager. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node2, + ); + }); + + test('after focus() after trying to focusNode() the same node twice returns focus()ed node', function () { + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; + this.focusManager.registerTree(this.testFocusableGroup1); + // Intentionally try to focus the same node twice. + this.focusManager.focusNode(this.testFocusableGroup1Node1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + document.getElementById('testFocusableGroup1.node2').focus(); + + // focus()ing a new node should overwrite a failed attempt to focusNode() the same node + // twice. This verifies that DOM focus syncing is properly reenabled by FocusManager. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node2, + ); + }); }); suite('CSS classes', function () { test('registered root focus()ed no prev focus returns root elem has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); @@ -3704,6 +4282,7 @@ suite('FocusManager', function () { test('registered node focus()ed no prev focus node elem has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); @@ -3720,6 +4299,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus same tree old node elem has no focus property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup1.node2').focus(); @@ -3738,6 +4319,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus same tree new node elem has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup1.node2').focus(); @@ -3756,6 +4339,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree old node elem has passive property', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3775,6 +4360,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree new node elem has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3793,6 +4380,8 @@ suite('FocusManager', function () { test('registered tree root focus()ed after prev node focus diff tree new root has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2').focus(); @@ -3812,6 +4401,9 @@ suite('FocusManager', function () { test('non-registered node subelement focus()ed nearest node has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById( + 'testFocusableGroup1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableGroup1.node2.unregisteredChild1') @@ -3830,10 +4422,12 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed has no focus', function () { + document.getElementById('testUnregisteredFocusableGroup3').tabIndex = + -1; + document.getElementById('testUnregisteredFocusableGroup3').focus(); assert.isNull(this.focusManager.getFocusedNode()); - const rootElem = document.getElementById( 'testUnregisteredFocusableGroup3', ); @@ -3848,12 +4442,15 @@ suite('FocusManager', function () { }); test('non-registered tree node focus()ed has no focus', function () { + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; + document .getElementById('testUnregisteredFocusableGroup3.node1') .focus(); assert.isNull(this.focusManager.getFocusedNode()); - const nodeElem = document.getElementById( 'testUnregisteredFocusableGroup3.node1', ); @@ -3869,6 +4466,7 @@ suite('FocusManager', function () { test('unfocusable element focus()ed after registered node focused original node has active focus', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -3899,6 +4497,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus removes focus', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3919,6 +4518,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus removes focus', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3938,6 +4538,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior removes focus from removed tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableGroup1.node1').focus(); @@ -3970,6 +4572,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently removes focus from removed tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -4002,6 +4606,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering removes active indicator', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -4034,6 +4641,9 @@ suite('FocusManager', function () { test('focus() multiple nodes in same tree with switches ensure passive focus has gone', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -4056,6 +4666,9 @@ suite('FocusManager', function () { test('registered tree focus()ed other tree node passively focused tree node now has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -4089,6 +4702,9 @@ suite('FocusManager', function () { test('focus on root, node in diff tree, then node in first tree; root should have focus gone', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -4119,6 +4735,7 @@ suite('FocusManager', function () { test('nested tree focus()ed with no prev root has active focus', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4').tabIndex = -1; document.getElementById('testFocusableNestedGroup4').focus(); @@ -4138,6 +4755,8 @@ suite('FocusManager', function () { test('nested tree node focus()ed with no prev focus node has active focus', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -4156,6 +4775,9 @@ suite('FocusManager', function () { test('nested tree node focus()ed after parent focused prev has passive node has active', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -4189,6 +4811,7 @@ suite('FocusManager', function () { test('Defocusing actively focused root HTML tree switches to passive highlight', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.focusTree(this.testFocusableTree2); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); @@ -4209,6 +4832,7 @@ suite('FocusManager', function () { test('Defocusing actively focused HTML tree node switches to passive highlight', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); @@ -4229,6 +4853,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); @@ -4248,6 +4873,8 @@ suite('FocusManager', function () { test('Refocusing actively focused root HTML tree restores to active highlight', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.focusTree(this.testFocusableTree2); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testFocusableTree2').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); document.getElementById('testFocusableTree2').focus(); @@ -4272,6 +4899,8 @@ suite('FocusManager', function () { test('Refocusing actively focused HTML tree node restores to active highlight', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -4299,6 +4928,8 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -4401,6 +5032,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusTree(this.testFocusableTree2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); @@ -4501,6 +5133,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); @@ -4532,6 +5165,7 @@ suite('FocusManager', function () { test('HTML DOM focus()ed then SVG focusTree()ed correctly updates getFocusedTree() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.focusTree(this.testFocusableGroup2); @@ -4566,6 +5200,7 @@ suite('FocusManager', function () { test('HTML DOM focus()ed then SVG focusNode()ed correctly updates getFocusedNode() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.focusNode(this.testFocusableGroup2Node1); @@ -4598,6 +5233,8 @@ suite('FocusManager', function () { test('HTML DOM focus()ed then SVG DOM focus()ed correctly updates getFocusedNode() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -4702,6 +5339,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); @@ -4802,6 +5440,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusNode(this.testFocusableGroup2Node1); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); @@ -4833,6 +5472,7 @@ suite('FocusManager', function () { test('SVG DOM focus()ed then HTML focusTree()ed correctly updates getFocusedTree() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.focusTree(this.testFocusableTree2); @@ -4867,6 +5507,7 @@ suite('FocusManager', function () { test('SVG DOM focus()ed then HTML focusNode()ed correctly updates getFocusedNode() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.focusNode(this.testFocusableTree2Node1); @@ -4899,6 +5540,8 @@ suite('FocusManager', function () { test('SVG DOM focus()ed then HTML DOM focus()ed correctly updates getFocusedNode() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -4933,6 +5576,21 @@ suite('FocusManager', function () { /* Ephemeral focus tests. */ suite('takeEphemeralFocus()', function () { + setup(function () { + // Ensure ephemeral-specific elements are focusable. + document.getElementById('nonTreeElementForEphemeralFocus').tabIndex = -1; + document.getElementById('nonTreeGroupForEphemeralFocus').tabIndex = -1; + }); + teardown(function () { + // Ensure ephemeral-specific elements have their tab indexes reset for a clean state. + document + .getElementById('nonTreeElementForEphemeralFocus') + .removeAttribute('tabindex'); + document + .getElementById('nonTreeGroupForEphemeralFocus') + .removeAttribute('tabindex'); + }); + test('with no focused node does not change states', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); @@ -5067,6 +5725,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; const ephemeralElement = document.getElementById( 'nonTreeGroupForEphemeralFocus', ); @@ -5241,6 +5900,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; const ephemeralElement = document.getElementById( 'nonTreeGroupForEphemeralFocus', ); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 09ef8820f0e..8b1124d06a6 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -39,97 +39,76 @@
-
+
Focusable tree 1 -
+
Tree 1 node 1 -
+
Tree 1 node 1 child 1
+ style="margin-left: 3em"> Tree 1 node 1 child 1 child 1 (unregistered)
-
+
Tree 1 node 2
+ style="margin-left: 2em"> Tree 1 node 2 child 2 (unregistered)
-
+
Tree 1 child 1 (unregistered)
-
+
Focusable tree 2 -
+
Tree 2 node 1 -
+
Nested tree 4 -
+
Tree 4 node 1 (nested)
+ style="margin-left: 4em"> Tree 4 node 1 child 1 (unregistered)
-
+
Nested tree 5 -
+
Tree 5 node 1 (nested)
-
+
Unregistered tree 3 -
+
Tree 3 node 1 (unregistered)
Unfocusable element
-
+
- - + + Group 1 node 1 - + Tree 1 node 1 child 1 - + Group 1 node 2 - + Tree 1 node 2 child 2 (unregistered) @@ -137,27 +116,27 @@ - - + + Group 2 node 1 - - + + Group 4 node 1 (nested) - - + + Tree 3 node 1 (unregistered) - +