Files
blockly/tests/mocha/widget_div_test.js
Ben Henning 0e16b0405a fix: Auto close drop-down divs on lost focus (reapply) (#9213)
## 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/563

### Proposed Changes

This introduces support in `FocusManager` to receive feedback on when an ephemerally focused element entirely loses focus (that is, neither it nor any of its descendants have focus).

This also introduces a behavior change for drop-down divs using the previously mentioned functionality to automatically close themselves when they lose focus for any reason (e.g. clicking outside of the div or tab navigating away from it).

Finally, and **importantly**, this adds a case where ephemeral focus does _not_ automatically return to the previously focused node: when focus is lost to the ephemerally focused element's tree and isn't instead put on another focused node.

### Reason for Changes

Ultimately, focus is probably the best proxy for cases when a drop-down div ought to no longer be open. However, tracking focus only within the scope of the drop-down div utility is rather difficult since a lot of the same problems that `FocusManager` handles also occur here (with regards to both descendants and outside elements receiving focus). It made more sense to expand `FocusManager`'s ephemeral focus support:
- It was easier to implement this `FocusManager` and in a way that's much more robust (since it's leveraging existing event handlers).
- Using `FocusManager` trivialized the solution for drop-down divs.
- There could be other use cases where custom ephemeral focus uses might benefit from knowing when they lose focus.

This new support is enabled by default for all drop-down divs, but can be disabled by callers if they wish to revert to the previous behavior of not auto-closing.

The change for whether to restore ephemeral focus was needed to fix a drawback that arises from the automatic returning of ephemeral focus introduced in this PR: when a user clicks out of an open drop-down menu it will restore focus back to the node that held focus prior to taking ephemeral focus (since it properly hides the drop-down div and restores focus). This creates awkward behavior issues for both mouse and keyboard users:
- For mouse: trying to open a drop-down outside of Blockly will automatically close the drop-down when the Blockly drop-down finishes closing (since focus is stolen back away from the thing the user clicked on).
- For keyboard: tab navigating out of Blockly tries to force focus back to Blockly.

**New in v2 of this PR**: Commit 0363d67c18 is the main one that prevents #9203 from being reintroduced by ensuring that widget div only clears its contents after ephemeral focus has returned. This was missed in the first audit since it wasn't clear that this line, in particular, can cause a div with focus to be removed and thus focus lost: dfd565957b/core/widgetdiv.ts (L156)

### Test Coverage

New tests have been added for both the drop-down div and `FocusManager` components, and have been verified as failing without the new behaviors in place.

There may be other edge cases worth testing for `FocusManager` in particular, but the tests introduced in this PR seem to cover the most important cases.

**New in v2 of this PR**: A test was added to validate that widget div now clears its contents only after ephemeral focus has returned to avoid the desyncing scenario that led to #9203. This test has been verified to fail without the fix. There are also a few new tests being added in the keyboard navigation plugin repository that also validate this behavior at a higher level (see https://github.com/google/blockly-keyboard-experimentation/pull/649).

Demonstration of the new behavior:

[Screen recording 2025-07-01 6.28.37 PM.webm](https://github.com/user-attachments/assets/7af29fed-1ba1-4828-a6cd-65bb94509e72)

### Documentation

No new documentation changes seem needed beyond the code documentation updates.

### Additional Information

It's also possible to change the automatic restoration behavior to be conditional instead of always assuming focus shouldn't be reset if focus leaves the ephemeral element, but that's probably a better change if there's an actual user issue discovered with this approach.

This was originally introduced in #9175 but it was reverted in #9204 due to #9203.
2025-07-07 15:52:38 -07:00

449 lines
14 KiB
JavaScript

/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
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 () {
function makeBBox(left, top, width, height) {
return {
left: left,
right: left + width,
top: top,
bottom: top + height,
width: width,
height: height,
};
}
setup(function () {
Blockly.WidgetDiv.createDom();
this.viewportBBox = makeBBox(0, 0, 1000, 1003);
this.widgetSize = {
width: 100,
height: 102,
};
this.anchorSize = {
width: 90,
height: 91,
};
this.testWidgetPosition = function (
anchorBBox,
rtl,
expectedX,
expectedY,
expectedHeight,
) {
Blockly.WidgetDiv.positionWithAnchor(
this.viewportBBox,
anchorBBox,
this.widgetSize,
rtl,
);
const style = Blockly.WidgetDiv.getDiv().style;
assert.equal(style.left, expectedX + 'px', 'Left');
assert.equal(style.top, expectedY + 'px', 'Top');
assert.equal(style.height, expectedHeight + 'px', 'Height');
};
});
suite('LTR', function () {
test('noConflict', function () {
// Anchor placed in the middle.
const anchorBBox = makeBBox(
500,
500,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed just below at the left side of the
// anchor.
const expectedX = anchorBBox.left;
const expectedY = anchorBBox.top + this.anchorSize.height;
this.testWidgetPosition(
anchorBBox,
false,
expectedX,
expectedY,
this.widgetSize.height,
);
});
test('topConflict', function () {
// Anchor close to the top.
const anchorBBox = makeBBox(
500,
50,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed just below the anchor.
const expectedX = anchorBBox.left;
const expectedY = anchorBBox.top + this.anchorSize.height;
this.testWidgetPosition(
anchorBBox,
false,
expectedX,
expectedY,
this.widgetSize.height,
);
});
test('bottomConflict', function () {
// Anchor placed close to the bottom.
const anchorBBox = makeBBox(
500,
900,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed just above the anchor.
const expectedX = anchorBBox.left;
const expectedY = anchorBBox.top - this.widgetSize.height;
this.testWidgetPosition(
anchorBBox,
false,
expectedX,
expectedY,
this.widgetSize.height,
);
});
test('leftConflict', function () {
// Anchor placed close to the left side.
const anchorBBox = makeBBox(
50,
500,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed at the anchor.
const expectedX = anchorBBox.left;
const expectedY = anchorBBox.top + this.anchorSize.height;
this.testWidgetPosition(
anchorBBox,
false,
expectedX,
expectedY,
this.widgetSize.height,
);
});
test('rightConflict', function () {
// Anchor placed close to the right side.
const anchorBBox = makeBBox(
950,
500,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed as far right as possible--at the edge of
// the screen.
const expectedX = this.viewportBBox.width - this.widgetSize.width;
const expectedY = anchorBBox.top + this.anchorSize.height;
this.testWidgetPosition(
anchorBBox,
false,
expectedX,
expectedY,
this.widgetSize.height,
);
});
});
suite('RTL', function () {
test('noConflict', function () {
// Anchor placed in the middle
const anchorBBox = makeBBox(
500,
500,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed at the right side of the anchor.
const expectedX = anchorBBox.right - this.widgetSize.width;
const expectedY = anchorBBox.top + this.anchorSize.height;
this.testWidgetPosition(
anchorBBox,
true,
expectedX,
expectedY,
this.widgetSize.height,
);
});
test('topConflict', function () {
// Anchor close to the top.
const anchorBBox = makeBBox(
500,
50,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed just below the anchor.
const expectedX = anchorBBox.right - this.widgetSize.width;
const expectedY = anchorBBox.top + this.anchorSize.height;
this.testWidgetPosition(
anchorBBox,
true,
expectedX,
expectedY,
this.widgetSize.height,
);
});
test('bottomConflict', function () {
// Anchor placed close to the bottom.
const anchorBBox = makeBBox(
500,
900,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed just above the anchor.
const expectedX = anchorBBox.right - this.widgetSize.width;
const expectedY = anchorBBox.top - this.widgetSize.height;
this.testWidgetPosition(
anchorBBox,
true,
expectedX,
expectedY,
this.widgetSize.height,
);
});
test('leftConflict', function () {
// Anchor placed close to the left side.
const anchorBBox = makeBBox(
10,
500,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed as far left as possible--at the edge of
// the screen.
const expectedX = 0;
const expectedY = anchorBBox.top + this.anchorSize.height;
this.testWidgetPosition(
anchorBBox,
true,
expectedX,
expectedY,
this.widgetSize.height,
);
});
test('rightConflict', function () {
// Anchor placed close to the right side.
const anchorBBox = makeBBox(
950,
500,
this.anchorSize.width,
this.anchorSize.height,
);
// The widget div should be placed as far right as possible--at the edge of
// the screen.
const expectedX = this.viewportBBox.width - this.widgetSize.width;
const expectedY = anchorBBox.top + this.anchorSize.height;
this.testWidgetPosition(
anchorBBox,
true,
expectedX,
expectedY,
this.widgetSize.height,
);
});
});
});
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();
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);
});
test('for showing nested div with ephemeral focus restores DOM focus', function () {
const block = this.setUpBlockWithField();
const field = Array.from(block.getFields())[0];
Blockly.getFocusManager().focusNode(block);
const nestedDiv = document.createElement('div');
nestedDiv.tabIndex = -1;
Blockly.WidgetDiv.getDiv().appendChild(nestedDiv);
Blockly.WidgetDiv.show(field, false, () => {}, null, true);
nestedDiv.focus(); // It's valid to focus this during ephemeral focus.
// Hiding will cause the now focused child div to be removed, leading to
// ephemeral focus being lost if the implementation doesn't handle
// returning ephemeral focus correctly.
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);
});
});
});