fix: Ensure focus changes when tabbing fields (#9173)

## 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/578
Fixes part of #8915

### Proposed Changes

Ensures fields update focus to the next field when tabbing between field editors. The old behavior can be observed in [#578](https://github.com/google/blockly-keyboard-experimentation/issues/578) and the new behavior can be observed here:

[Screen recording 2025-06-25 1.39.28 PM.webm](https://github.com/user-attachments/assets/e00fcb55-5c20-4d5c-81a8-be9049cc0d7e)

### Reason for Changes

Having focus reset back to the original field editor that was opened is an unexpected experience for users. This approach is better.

Note that there are some separate changes added here, as well:
- Connections and fields now check if their block IDs contain their indicator prefixes since this will all-but-guarantee focus breaks for those nodes. This is an excellent example of why #9171 is needed.
- Some minor naming updates for `FieldInput`: it was incorrectly implying that key events are sent for changes to the `input` element used by the field editor, but they are actually `InputEvent`s per https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event.

### Test Coverage

New tests were added for field editing in general (since this seems to be missing), including tabbing support to ensure the fixes originally introduced in #9049.

One new test has been added specifically for verifying that focus updates with tabbing. This has been verified to fail with the fix removed (as have all tabbing tests with the tabbing code from #9049 removed).

Some specific notes for the test changes:
- There's a slight test dependency inversion happening here. `FieldInput` is being tested with a specific `FieldNumber` class via real block loading. This isn't ideal, but it seems fine given the circumstances (otherwise a lot of extra setup would be necessary for the tests).
- The workspace actually needs to be made visible during tests in order for focus to work correctly (though it's reset at the end of each test, but this may cause some flickering while the tests are running).
- It's the case that a bunch of tests were actually setting up blocks incorrectly (i.e. not defining a must-have `id` property which caused some issues with the new field and connection ID validation checks). These tests have been corrected, but it's worth noting that the blocks are likely still technically wrong since they are not conforming to their TypeScript contracts.
- Similar to the previous point, one test was incorrectly setting the first ID to be returned by the ID generator as `undefined` since (presumably due to a copy-and-paste error when the test was introduced) it was referencing a `TEST_BLOCK_ID` property that hadn't been defined for that test suite. This has been corrected as, without it, there are failures due to the new validation checks.
- For the connection database checks, a new ID is generated instead of fixing the block ID to ensure that it's always unique even if called multiple times (otherwise a block ID would need to be piped through from the calling tests, or an invalid situation would need to be introduced where multiple blocks shared an ID; the former seemed unnecessary and the latter seemed nonideal).
- There are distinct Geras/Zelos tests to validate the case where a full-block field should have its parent block, rather than the field itself, focused on tabbing. See this conversation for more context: https://github.com/google/blockly/pull/9173#discussion_r2172921455.

### Documentation

No documentation changes should be needed here.

### Additional Information

Nothing to add.
This commit is contained in:
Ben Henning
2025-07-02 16:07:05 -07:00
committed by GitHub
parent 5acd072f05
commit 1e37d21f0a
7 changed files with 364 additions and 18 deletions

View File

@@ -83,6 +83,12 @@ export class Connection {
public type: number,
) {
this.sourceBlock_ = source;
if (source.id.includes('_connection')) {
throw new Error(
`Connection ID indicator is contained in block ID. This will cause ` +
`problems with focus: ${source.id}.`,
);
}
this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`;
}

View File

@@ -265,6 +265,12 @@ export abstract class Field<T = any>
throw Error('Field already bound to a block');
}
this.sourceBlock_ = block;
if (block.id.includes('_field')) {
throw new Error(
`Field ID indicator is contained in block ID. This may cause ` +
`problems with focus: ${block.id}.`,
);
}
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
}

View File

@@ -27,6 +27,7 @@ import {
FieldValidator,
UnattachedFieldError,
} from './field.js';
import {getFocusManager} from './focus_manager.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import {Msg} from './msg.js';
import * as renderManagement from './render_management.js';
@@ -83,8 +84,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
/** Key down event data. */
private onKeyDownWrapper: browserEvents.Data | null = null;
/** Key input event data. */
private onKeyInputWrapper: browserEvents.Data | null = null;
/** Input element input event data. */
private onInputWrapper: browserEvents.Data | null = null;
/**
* Whether the field should consider the whole parent block to be its click
@@ -558,7 +559,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
this.onHtmlInputKeyDown_,
);
// Resize after every input change.
this.onKeyInputWrapper = browserEvents.conditionalBind(
this.onInputWrapper = browserEvents.conditionalBind(
htmlInput,
'input',
this,
@@ -572,9 +573,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
browserEvents.unbind(this.onKeyDownWrapper);
this.onKeyDownWrapper = null;
}
if (this.onKeyInputWrapper) {
browserEvents.unbind(this.onKeyInputWrapper);
this.onKeyInputWrapper = null;
if (this.onInputWrapper) {
browserEvents.unbind(this.onInputWrapper);
this.onInputWrapper = null;
}
}
@@ -614,6 +615,14 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (target instanceof FieldInput) {
WidgetDiv.hideIfOwner(this);
dropDownDiv.hideWithoutAnimation();
const targetSourceBlock = target.getSourceBlock();
if (
target.isFullBlockField() &&
targetSourceBlock &&
targetSourceBlock instanceof BlockSvg
) {
getFocusManager().focusNode(targetSourceBlock);
} else getFocusManager().focusNode(target);
target.showEditor();
}
}
@@ -622,7 +631,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
/**
* Handle a change to the editor.
*
* @param _e Keyboard event.
* @param _e InputEvent.
*/
private onHtmlInputChange(_e: Event) {
// Intermediate value changes from user input are not confirmed until the

View File

@@ -29,7 +29,10 @@ suite('Connection checker', function () {
}
test('Target Null', function () {
const connection = new Blockly.Connection({}, ConnectionType.INPUT_VALUE);
const connection = new Blockly.Connection(
{id: 'test'},
ConnectionType.INPUT_VALUE,
);
assertReasonHelper(
this.checker,
connection,
@@ -38,7 +41,7 @@ suite('Connection checker', function () {
);
});
test('Target Self', function () {
const block = {workspace: 1};
const block = {id: 'test', workspace: 1};
const connection1 = new Blockly.Connection(
block,
ConnectionType.INPUT_VALUE,
@@ -57,11 +60,11 @@ suite('Connection checker', function () {
});
test('Different Workspaces', function () {
const connection1 = new Blockly.Connection(
{workspace: 1},
{id: 'test1', workspace: 1},
ConnectionType.INPUT_VALUE,
);
const connection2 = new Blockly.Connection(
{workspace: 2},
{id: 'test2', workspace: 2},
ConnectionType.OUTPUT_VALUE,
);
@@ -76,10 +79,10 @@ suite('Connection checker', function () {
setup(function () {
// We have to declare each separately so that the connections belong
// on different blocks.
const prevBlock = {isShadow: function () {}};
const nextBlock = {isShadow: function () {}};
const outBlock = {isShadow: function () {}};
const inBlock = {isShadow: function () {}};
const prevBlock = {id: 'test1', isShadow: function () {}};
const nextBlock = {id: 'test2', isShadow: function () {}};
const outBlock = {id: 'test3', isShadow: function () {}};
const inBlock = {id: 'test4', isShadow: function () {}};
this.previous = new Blockly.Connection(
prevBlock,
ConnectionType.PREVIOUS_STATEMENT,
@@ -197,11 +200,13 @@ suite('Connection checker', function () {
suite('Shadows', function () {
test('Previous Shadow', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const nextBlock = {
id: 'test2',
isShadow: function () {
return false;
},
@@ -224,11 +229,13 @@ suite('Connection checker', function () {
});
test('Next Shadow', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {
return false;
},
};
const nextBlock = {
id: 'test2',
isShadow: function () {
return true;
},
@@ -251,11 +258,13 @@ suite('Connection checker', function () {
});
test('Prev and Next Shadow', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const nextBlock = {
id: 'test2',
isShadow: function () {
return true;
},
@@ -278,11 +287,13 @@ suite('Connection checker', function () {
});
test('Output Shadow', function () {
const outBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const inBlock = {
id: 'test2',
isShadow: function () {
return false;
},
@@ -305,11 +316,13 @@ suite('Connection checker', function () {
});
test('Input Shadow', function () {
const outBlock = {
id: 'test1',
isShadow: function () {
return false;
},
};
const inBlock = {
id: 'test2',
isShadow: function () {
return true;
},
@@ -332,11 +345,13 @@ suite('Connection checker', function () {
});
test('Output and Input Shadow', function () {
const outBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const inBlock = {
id: 'test2',
isShadow: function () {
return true;
},
@@ -373,9 +388,11 @@ suite('Connection checker', function () {
};
test('Output connected, adding previous', function () {
const outBlock = {
id: 'test1',
isShadow: function () {},
};
const inBlock = {
id: 'test2',
isShadow: function () {},
};
const outCon = new Blockly.Connection(
@@ -394,6 +411,7 @@ suite('Connection checker', function () {
ConnectionType.PREVIOUS_STATEMENT,
);
const nextBlock = {
id: 'test3',
isShadow: function () {},
};
const nextCon = new Blockly.Connection(
@@ -410,9 +428,11 @@ suite('Connection checker', function () {
});
test('Previous connected, adding output', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {},
};
const nextBlock = {
id: 'test2',
isShadow: function () {},
};
const prevCon = new Blockly.Connection(
@@ -431,6 +451,7 @@ suite('Connection checker', function () {
ConnectionType.OUTPUT_VALUE,
);
const inBlock = {
id: 'test3',
isShadow: function () {},
};
const inCon = new Blockly.Connection(
@@ -449,8 +470,14 @@ suite('Connection checker', function () {
});
suite('Check Types', function () {
setup(function () {
this.con1 = new Blockly.Connection({}, ConnectionType.PREVIOUS_STATEMENT);
this.con2 = new Blockly.Connection({}, ConnectionType.NEXT_STATEMENT);
this.con1 = new Blockly.Connection(
{id: 'test1'},
ConnectionType.PREVIOUS_STATEMENT,
);
this.con2 = new Blockly.Connection(
{id: 'test2'},
ConnectionType.NEXT_STATEMENT,
);
});
function assertCheckTypes(checker, one, two) {
assert.isTrue(checker.doTypeChecks(one, two));

View File

@@ -5,6 +5,7 @@
*/
import {ConnectionType} from '../../build/src/core/connection_type.js';
import * as idGenerator from '../../build/src/core/utils/idgenerator.js';
import {assert} from '../../node_modules/chai/chai.js';
import {
sharedTestSetup,
@@ -31,7 +32,7 @@ suite('Connection Database', function () {
};
workspace.connectionDBList[type] = opt_database || this.database;
const connection = new Blockly.RenderedConnection(
{workspace: workspace},
{id: idGenerator.getNextUniqueId(), workspace: workspace},
type,
);
connection.x = x;

View File

@@ -355,6 +355,7 @@ suite('Events', function () {
suite('With variable getter blocks', function () {
setup(function () {
this.TEST_BLOCK_ID = 'test_block_id';
this.genUidStub = createGenUidStubWithReturns([
this.TEST_BLOCK_ID,
'test_var_id',

View File

@@ -294,4 +294,300 @@ suite('Text Input Fields', function () {
this.assertValue('test text');
});
});
suite('Use editor', function () {
setup(function () {
this.blockJson = {
'type': 'math_arithmetic',
'id': 'test_arithmetic_block',
'fields': {
'OP': 'ADD',
},
'inputs': {
'A': {
'shadow': {
'type': 'math_number',
'id': 'left_input_block',
'name': 'test_name',
'fields': {
'NUM': 1,
},
},
},
'B': {
'shadow': {
'type': 'math_number',
'id': 'right_input_block',
'fields': {
'NUM': 2,
},
},
},
},
};
this.getFieldFromShadowBlock = function (shadowBlock) {
return shadowBlock.getFields().next().value;
};
this.simulateTypingIntoInput = (inputElem, newText) => {
// Typing into an input field changes its value directly and then fires
// an InputEvent (which FieldInput relies on to automatically
// synchronize its state).
inputElem.value = newText;
inputElem.dispatchEvent(new InputEvent('input'));
};
});
// The block being tested doesn't use full-block fields in Geras.
suite('Geras theme', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
Blockly.serialization.blocks.append(this.blockJson, this.workspace);
// The workspace actually needs to be visible for focus.
document.getElementById('blocklyDiv').style.visibility = 'visible';
});
teardown(function () {
document.getElementById('blocklyDiv').style.visibility = 'hidden';
workspaceTeardown.call(this, this.workspace);
});
test('No editor open by default', function () {
// The editor is only opened if its indicated that it should be open.
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Type in editor with escape does not change field value', async function () {
const block = this.workspace.getBlockById('left_input_block');
const field = this.getFieldFromShadowBlock(block);
field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(fieldEditor, 'updated value');
fieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
}),
);
// 'Escape' will avoid saving the edited field value and close the editor.
assert.equal(field.getValue(), 1);
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Type in editor with enter changes field value', async function () {
const block = this.workspace.getBlockById('left_input_block');
const field = this.getFieldFromShadowBlock(block);
field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(fieldEditor, '10');
fieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// 'Enter' will save the edited result and close the editor.
assert.equal(field.getValue(), 10);
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Not finishing editing does not return ephemeral focus', async function () {
const block = this.workspace.getBlockById('left_input_block');
const field = this.getFieldFromShadowBlock(block);
Blockly.getFocusManager().focusNode(field);
field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(fieldEditor, '10');
// If the editor doesn't restore focus then the current focused element is
// still the editor.
assert.strictEqual(document.activeElement, fieldEditor);
});
test('Finishing editing returns ephemeral focus', async function () {
const block = this.workspace.getBlockById('left_input_block');
const field = this.getFieldFromShadowBlock(block);
Blockly.getFocusManager().focusNode(field);
field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(fieldEditor, '10');
fieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
}),
);
// Verify that exiting the editor restores focus back to the field.
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), field);
assert.strictEqual(document.activeElement, field.getFocusableElement());
});
test('Opening an editor, tabbing, then editing changes the second field', async function () {
const leftInputBlock = this.workspace.getBlockById('left_input_block');
const rightInputBlock =
this.workspace.getBlockById('right_input_block');
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
const rightField = this.getFieldFromShadowBlock(rightInputBlock);
leftField.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Tab, then edit and close the editor.
document.querySelector('.blocklyHtmlInput').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
}),
);
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(rightFieldEditor, '15');
rightFieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// Verify that only the right field changed (due to the tab).
assert.equal(leftField.getValue(), 1);
assert.equal(rightField.getValue(), 15);
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Opening an editor, tabbing, then editing changes focus to the second field', async function () {
const leftInputBlock = this.workspace.getBlockById('left_input_block');
const rightInputBlock =
this.workspace.getBlockById('right_input_block');
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
const rightField = this.getFieldFromShadowBlock(rightInputBlock);
Blockly.getFocusManager().focusNode(leftField);
leftField.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Tab, then edit and close the editor.
document.querySelector('.blocklyHtmlInput').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
}),
);
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(rightFieldEditor, '15');
rightFieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// Verify that the tab causes focus to change to the right field.
assert.strictEqual(
Blockly.getFocusManager().getFocusedNode(),
rightField,
);
assert.strictEqual(
document.activeElement,
rightField.getFocusableElement(),
);
});
});
// The block being tested uses full-block fields in Zelos.
suite('Zelos theme', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'zelos',
});
Blockly.serialization.blocks.append(this.blockJson, this.workspace);
// The workspace actually needs to be visible for focus.
document.getElementById('blocklyDiv').style.visibility = 'visible';
});
teardown(function () {
document.getElementById('blocklyDiv').style.visibility = 'hidden';
workspaceTeardown.call(this, this.workspace);
});
test('Opening an editor, tabbing, then editing full block field changes the second field', async function () {
const leftInputBlock = this.workspace.getBlockById('left_input_block');
const rightInputBlock =
this.workspace.getBlockById('right_input_block');
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
const rightField = this.getFieldFromShadowBlock(rightInputBlock);
leftField.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Tab, then edit and close the editor.
document.querySelector('.blocklyHtmlInput').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
}),
);
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(rightFieldEditor, '15');
rightFieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// Verify that only the right field changed (due to the tab).
assert.equal(leftField.getValue(), 1);
assert.equal(rightField.getValue(), 15);
assert.isNull(document.querySelector('.blocklyHtmlInput'));
});
test('Opening an editor, tabbing, then editing full block field changes focus to the second field', async function () {
const leftInputBlock = this.workspace.getBlockById('left_input_block');
const rightInputBlock =
this.workspace.getBlockById('right_input_block');
const leftField = this.getFieldFromShadowBlock(leftInputBlock);
Blockly.getFocusManager().focusNode(leftInputBlock);
leftField.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();
// Tab, then edit and close the editor.
document.querySelector('.blocklyHtmlInput').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
}),
);
const rightFieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(rightFieldEditor, '15');
rightFieldEditor.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
}),
);
// Verify that the tab causes focus to change to the right field block.
assert.strictEqual(
Blockly.getFocusManager().getFocusedNode(),
rightInputBlock,
);
assert.strictEqual(
document.activeElement,
rightInputBlock.getFocusableElement(),
);
});
});
});
});