fix: Revert drop down and widget div PRs (#9222)

* Revert "fix: Auto-close widget divs on lost focus (#9216)"

This reverts commit bea183d85d.

* Revert "fix: Auto close drop-down divs on lost focus (reapply) (#9213)"

This reverts commit 0e16b0405a.
This commit is contained in:
Ben Henning
2025-07-09 12:13:33 -07:00
committed by GitHub
parent bea183d85d
commit 5747feef45
7 changed files with 36 additions and 477 deletions

View File

@@ -213,8 +213,6 @@ export function setColour(backgroundColour: string, borderColour: string) {
* passed in here then callers should manage ephemeral focus directly * passed in here then callers should manage ephemeral focus directly
* otherwise focus may not properly restore when the widget closes. Defaults * otherwise focus may not properly restore when the widget closes. Defaults
* to true. * to true.
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
* if it loses DOM focus for any reason.
* @returns True if the menu rendered below block; false if above. * @returns True if the menu rendered below block; false if above.
*/ */
export function showPositionedByBlock<T>( export function showPositionedByBlock<T>(
@@ -223,13 +221,11 @@ export function showPositionedByBlock<T>(
opt_onHide?: () => void, opt_onHide?: () => void,
opt_secondaryYOffset?: number, opt_secondaryYOffset?: number,
manageEphemeralFocus: boolean = true, manageEphemeralFocus: boolean = true,
autoCloseOnLostFocus: boolean = true,
): boolean { ): boolean {
return showPositionedByRect( return showPositionedByRect(
getScaledBboxOfBlock(block), getScaledBboxOfBlock(block),
field as Field, field as Field,
manageEphemeralFocus, manageEphemeralFocus,
autoCloseOnLostFocus,
opt_onHide, opt_onHide,
opt_secondaryYOffset, opt_secondaryYOffset,
); );
@@ -249,8 +245,6 @@ export function showPositionedByBlock<T>(
* passed in here then callers should manage ephemeral focus directly * passed in here then callers should manage ephemeral focus directly
* otherwise focus may not properly restore when the widget closes. Defaults * otherwise focus may not properly restore when the widget closes. Defaults
* to true. * to true.
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
* if it loses DOM focus for any reason.
* @returns True if the menu rendered below block; false if above. * @returns True if the menu rendered below block; false if above.
*/ */
export function showPositionedByField<T>( export function showPositionedByField<T>(
@@ -258,14 +252,12 @@ export function showPositionedByField<T>(
opt_onHide?: () => void, opt_onHide?: () => void,
opt_secondaryYOffset?: number, opt_secondaryYOffset?: number,
manageEphemeralFocus: boolean = true, manageEphemeralFocus: boolean = true,
autoCloseOnLostFocus: boolean = true,
): boolean { ): boolean {
positionToField = true; positionToField = true;
return showPositionedByRect( return showPositionedByRect(
getScaledBboxOfField(field as Field), getScaledBboxOfField(field as Field),
field as Field, field as Field,
manageEphemeralFocus, manageEphemeralFocus,
autoCloseOnLostFocus,
opt_onHide, opt_onHide,
opt_secondaryYOffset, opt_secondaryYOffset,
); );
@@ -310,15 +302,12 @@ function getScaledBboxOfField(field: Field): Rect {
* according to the drop-down div's lifetime. Note that if a false value is * 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 * passed in here then callers should manage ephemeral focus directly
* otherwise focus may not properly restore when the widget closes. * otherwise focus may not properly restore when the widget closes.
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
* if it loses DOM focus for any reason.
* @returns True if the menu rendered below block; false if above. * @returns True if the menu rendered below block; false if above.
*/ */
function showPositionedByRect( function showPositionedByRect(
bBox: Rect, bBox: Rect,
field: Field, field: Field,
manageEphemeralFocus: boolean, manageEphemeralFocus: boolean,
autoCloseOnLostFocus: boolean,
opt_onHide?: () => void, opt_onHide?: () => void,
opt_secondaryYOffset?: number, opt_secondaryYOffset?: number,
): boolean { ): boolean {
@@ -347,7 +336,6 @@ function showPositionedByRect(
secondaryY, secondaryY,
manageEphemeralFocus, manageEphemeralFocus,
opt_onHide, opt_onHide,
autoCloseOnLostFocus,
); );
} }
@@ -366,11 +354,9 @@ function showPositionedByRect(
* @param primaryY Desired origin point y, in absolute px. * @param primaryY Desired origin point y, in absolute px.
* @param secondaryX Secondary/alternative origin point x, in absolute px. * @param secondaryX Secondary/alternative origin point x, in absolute px.
* @param secondaryY Secondary/alternative origin point y, in absolute px. * @param secondaryY Secondary/alternative origin point y, in absolute px.
* @param opt_onHide Optional callback for when the drop-down is hidden.
* @param manageEphemeralFocus Whether ephemeral focus should be managed * @param manageEphemeralFocus Whether ephemeral focus should be managed
* according to the widget div's lifetime. * according to the widget div's lifetime.
* @param opt_onHide Optional callback for when the drop-down is hidden.
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
* if it loses DOM focus for any reason.
* @returns True if the menu rendered at the primary origin point. * @returns True if the menu rendered at the primary origin point.
* @internal * @internal
*/ */
@@ -383,7 +369,6 @@ export function show<T>(
secondaryY: number, secondaryY: number,
manageEphemeralFocus: boolean, manageEphemeralFocus: boolean,
opt_onHide?: () => void, opt_onHide?: () => void,
autoCloseOnLostFocus?: boolean,
): boolean { ): boolean {
owner = newOwner as Field; owner = newOwner as Field;
onHide = opt_onHide || null; onHide = opt_onHide || null;
@@ -409,18 +394,7 @@ export function show<T>(
// Ephemeral focus must happen after the div is fully visible in order to // Ephemeral focus must happen after the div is fully visible in order to
// ensure that it properly receives focus. // ensure that it properly receives focus.
if (manageEphemeralFocus) { if (manageEphemeralFocus) {
const autoCloseCallback = autoCloseOnLostFocus returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div);
? (hasFocus: boolean) => {
// If focus is ever lost, close the drop-down.
if (!hasFocus) {
hide();
}
}
: null;
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(
div,
autoCloseCallback,
);
} }
return atOrigin; return atOrigin;
@@ -719,6 +693,7 @@ export function hideWithoutAnimation() {
onHide(); onHide();
onHide = null; onHide = null;
} }
clearContent();
owner = null; owner = null;
(common.getMainWorkspace() as WorkspaceSvg).markFocused(); (common.getMainWorkspace() as WorkspaceSvg).markFocused();
@@ -727,13 +702,6 @@ export function hideWithoutAnimation() {
returnEphemeralFocus(); returnEphemeralFocus();
returnEphemeralFocus = null; returnEphemeralFocus = null;
} }
// Content must be cleared after returning ephemeral focus since otherwise it
// may force focus changes which could desynchronize the focus manager and
// make it think the user directed focus away from the drop-down div (which
// will then notify it to not restore focus back to any previously focused
// node).
clearContent();
} }
/** /**

View File

@@ -17,14 +17,6 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
*/ */
export type ReturnEphemeralFocus = () => void; export type ReturnEphemeralFocus = () => void;
/**
* Type declaration for an optional callback to observe when an element with
* ephemeral focus has its DOM focus changed before ephemeral focus is returned.
*
* See FocusManager.takeEphemeralFocus for more details.
*/
export type EphemeralFocusChangedInDom = (hasDomFocus: boolean) => void;
/** /**
* Represents an IFocusableTree that has been registered for focus management in * Represents an IFocusableTree that has been registered for focus management in
* FocusManager. * FocusManager.
@@ -86,10 +78,7 @@ export class FocusManager {
private previouslyFocusedNode: IFocusableNode | null = null; private previouslyFocusedNode: IFocusableNode | null = null;
private registeredTrees: Array<TreeRegistration> = []; private registeredTrees: Array<TreeRegistration> = [];
private ephemerallyFocusedElement: HTMLElement | SVGElement | null = null; private currentlyHoldsEphemeralFocus: boolean = false;
private ephemeralDomFocusChangedCallback: EphemeralFocusChangedInDom | null =
null;
private ephemerallyFocusedElementCurrentlyHasFocus: boolean = false;
private lockFocusStateChanges: boolean = false; private lockFocusStateChanges: boolean = false;
private recentlyLostAllFocus: boolean = false; private recentlyLostAllFocus: boolean = false;
private isUpdatingFocusedNode: boolean = false; private isUpdatingFocusedNode: boolean = false;
@@ -129,21 +118,6 @@ export class FocusManager {
} else { } else {
this.defocusCurrentFocusedNode(); this.defocusCurrentFocusedNode();
} }
const ephemeralFocusElem = this.ephemerallyFocusedElement;
if (ephemeralFocusElem) {
const hadFocus = this.ephemerallyFocusedElementCurrentlyHasFocus;
const hasFocus =
!!element &&
element instanceof Node &&
ephemeralFocusElem.contains(element);
if (hadFocus !== hasFocus) {
this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus;
if (this.ephemeralDomFocusChangedCallback) {
this.ephemeralDomFocusChangedCallback(hasFocus);
}
}
}
}; };
// Register root document focus listeners for tracking when focus leaves all // Register root document focus listeners for tracking when focus leaves all
@@ -339,7 +313,7 @@ export class FocusManager {
*/ */
focusNode(focusableNode: IFocusableNode): void { focusNode(focusableNode: IFocusableNode): void {
this.ensureManagerIsUnlocked(); this.ensureManagerIsUnlocked();
const mustRestoreUpdatingNode = !this.ephemerallyFocusedElement; const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus;
if (mustRestoreUpdatingNode) { if (mustRestoreUpdatingNode) {
// Disable state syncing from DOM events since possible calls to focus() // Disable state syncing from DOM events since possible calls to focus()
// below will loop a call back to focusNode(). // below will loop a call back to focusNode().
@@ -421,7 +395,7 @@ export class FocusManager {
this.removeHighlight(nextTreeRoot); this.removeHighlight(nextTreeRoot);
} }
if (!this.ephemerallyFocusedElement) { if (!this.currentlyHoldsEphemeralFocus) {
// Only change the actively focused node if ephemeral state isn't held. // Only change the actively focused node if ephemeral state isn't held.
this.activelyFocusNode(nodeToFocus, prevTree ?? null); this.activelyFocusNode(nodeToFocus, prevTree ?? null);
} }
@@ -449,50 +423,24 @@ export class FocusManager {
* the returned lambda is called. Additionally, only 1 ephemeral focus context * the returned lambda is called. Additionally, only 1 ephemeral focus context
* can be active at any given time (attempting to activate more than one * can be active at any given time (attempting to activate more than one
* simultaneously will result in an error being thrown). * simultaneously will result in an error being thrown).
*
* Important details regarding the onFocusChangedInDom callback:
* - This method will be called initially with a value of 'true' indicating
* that the ephemeral element has been focused, so callers can rely on that,
* if needed, for initialization logic.
* - It's safe to end ephemeral focus in this callback (and is encouraged for
* callers that wish to automatically end ephemeral focus when the user
* directs focus outside of the element).
* - The element AND all of its descendants are tracked for focus. That means
* the callback will ONLY be called with a value of 'false' if focus
* completely leaves the DOM tree for the provided focusable element.
* - It's invalid to return focus on the very first call to the callback,
* however this is expected to be impossible, anyway, since this method
* won't return until after the first call to the callback (thus there will
* be no means to return ephemeral focus).
*
* @param focusableElement The element that should be focused until returned.
* @param onFocusChangedInDom An optional callback which will be notified
* whenever the provided element's focus changes before ephemeral focus is
* returned. See the details above for specifics.
* @returns A ReturnEphemeralFocus that must be called when ephemeral focus
* should end.
*/ */
takeEphemeralFocus( takeEphemeralFocus(
focusableElement: HTMLElement | SVGElement, focusableElement: HTMLElement | SVGElement,
onFocusChangedInDom: EphemeralFocusChangedInDom | null = null,
): ReturnEphemeralFocus { ): ReturnEphemeralFocus {
this.ensureManagerIsUnlocked(); this.ensureManagerIsUnlocked();
if (this.ephemerallyFocusedElement) { if (this.currentlyHoldsEphemeralFocus) {
throw Error( throw Error(
`Attempted to take ephemeral focus when it's already held, ` + `Attempted to take ephemeral focus when it's already held, ` +
`with new element: ${focusableElement}.`, `with new element: ${focusableElement}.`,
); );
} }
this.ephemerallyFocusedElement = focusableElement; this.currentlyHoldsEphemeralFocus = true;
this.ephemeralDomFocusChangedCallback = onFocusChangedInDom;
if (this.focusedNode) { if (this.focusedNode) {
this.passivelyFocusNode(this.focusedNode, null); this.passivelyFocusNode(this.focusedNode, null);
} }
focusableElement.focus(); focusableElement.focus();
this.ephemerallyFocusedElementCurrentlyHasFocus = true;
const focusedNodeAtStart = this.focusedNode;
let hasFinishedEphemeralFocus = false; let hasFinishedEphemeralFocus = false;
return () => { return () => {
if (hasFinishedEphemeralFocus) { if (hasFinishedEphemeralFocus) {
@@ -502,22 +450,9 @@ export class FocusManager {
); );
} }
hasFinishedEphemeralFocus = true; hasFinishedEphemeralFocus = true;
this.ephemerallyFocusedElement = null; this.currentlyHoldsEphemeralFocus = false;
this.ephemeralDomFocusChangedCallback = null;
const hadEphemeralFocusAtEnd = if (this.focusedNode) {
this.ephemerallyFocusedElementCurrentlyHasFocus;
this.ephemerallyFocusedElementCurrentlyHasFocus = false;
// If the user forced away DOM focus during ephemeral focus, then
// determine whether focus should be restored back to a focusable node
// after ephemeral focus ends. Generally it shouldn't be, but in some
// cases (such as the user focusing an actual focusable node) it then
// should be.
const hasNewFocusedNode = focusedNodeAtStart !== this.focusedNode;
const shouldRestoreToNode = hasNewFocusedNode || hadEphemeralFocusAtEnd;
if (this.focusedNode && shouldRestoreToNode) {
this.activelyFocusNode(this.focusedNode, null); this.activelyFocusNode(this.focusedNode, null);
// Even though focus was restored, check if it's lost again. It's // Even though focus was restored, check if it's lost again. It's
@@ -535,11 +470,6 @@ export class FocusManager {
this.focusNode(capturedNode); this.focusNode(capturedNode);
} }
}, 0); }, 0);
} else {
// If the ephemeral element lost focus then do not force it back since
// that likely will override the user's own attempt to move focus away
// from the ephemeral experience.
this.defocusCurrentFocusedNode();
} }
}; };
} }
@@ -548,7 +478,7 @@ export class FocusManager {
* @returns whether something is currently holding ephemeral focus * @returns whether something is currently holding ephemeral focus
*/ */
ephemeralFocusTaken(): boolean { ephemeralFocusTaken(): boolean {
return !!this.ephemerallyFocusedElement; return this.currentlyHoldsEphemeralFocus;
} }
/** /**
@@ -586,7 +516,7 @@ export class FocusManager {
// The current node will likely be defocused while ephemeral focus is held, // The current node will likely be defocused while ephemeral focus is held,
// but internal manager state shouldn't change since the node should be // but internal manager state shouldn't change since the node should be
// restored upon exiting ephemeral focus mode. // restored upon exiting ephemeral focus mode.
if (this.focusedNode && !this.ephemerallyFocusedElement) { if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) {
this.passivelyFocusNode(this.focusedNode, null); this.passivelyFocusNode(this.focusedNode, null);
this.updateFocusedNode(null); this.updateFocusedNode(null);
} }

View File

@@ -99,8 +99,6 @@ export function createDom() {
* passed in here then callers should manage ephemeral focus directly * passed in here then callers should manage ephemeral focus directly
* otherwise focus may not properly restore when the widget closes. Defaults * otherwise focus may not properly restore when the widget closes. Defaults
* to true. * to true.
* @param autoCloseOnLostFocus Whether the widget should automatically hide if
* it loses DOM focus for any reason.
*/ */
export function show( export function show(
newOwner: unknown, newOwner: unknown,
@@ -108,7 +106,6 @@ export function show(
newDispose: () => void, newDispose: () => void,
workspace?: WorkspaceSvg | null, workspace?: WorkspaceSvg | null,
manageEphemeralFocus: boolean = true, manageEphemeralFocus: boolean = true,
autoCloseOnLostFocus: boolean = true,
) { ) {
hide(); hide();
owner = newOwner; owner = newOwner;
@@ -134,18 +131,7 @@ export function show(
dom.addClass(div, themeClassName); dom.addClass(div, themeClassName);
} }
if (manageEphemeralFocus) { if (manageEphemeralFocus) {
const autoCloseCallback = autoCloseOnLostFocus returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div);
? (hasFocus: boolean) => {
// If focus is ever lost, close the widget.
if (!hasFocus) {
hide();
}
}
: null;
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(
div,
autoCloseCallback,
);
} }
} }
@@ -160,18 +146,6 @@ export function hide() {
const div = containerDiv; const div = containerDiv;
if (!div) return; if (!div) return;
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
if (returnEphemeralFocus) {
returnEphemeralFocus();
returnEphemeralFocus = null;
}
// Content must be cleared after returning ephemeral focus since otherwise it
// may force focus changes which could desynchronize the focus manager and
// make it think the user directed focus away from the widget div (which will
// then notify it to not restore focus back to any previously focused node).
div.style.display = 'none'; div.style.display = 'none';
div.style.left = ''; div.style.left = '';
div.style.top = ''; div.style.top = '';
@@ -189,6 +163,12 @@ export function hide() {
dom.removeClass(div, themeClassName); dom.removeClass(div, themeClassName);
themeClassName = ''; themeClassName = '';
} }
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
if (returnEphemeralFocus) {
returnEphemeralFocus();
returnEphemeralFocus = null;
}
} }
/** /**

View File

@@ -252,34 +252,6 @@ suite('DropDownDiv', function () {
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block);
assert.strictEqual(document.activeElement, dropDownDivElem); assert.strictEqual(document.activeElement, dropDownDivElem);
}); });
test('without auto close on lost focus lost focus does not hide 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, false);
// Focus an element outside of the drop-down.
document.getElementById('nonTreeElementForEphemeralFocus').focus();
// Even though the drop-down lost focus, it should still be visible.
const dropDownDivElem = document.querySelector('.blocklyDropDownDiv');
assert.strictEqual(dropDownDivElem.style.opacity, '1');
});
test('with auto close on lost focus lost focus hides 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, true);
// Focus an element outside of the drop-down.
document.getElementById('nonTreeElementForEphemeralFocus').focus();
// The drop-down should now be hidden since it lost focus.
const dropDownDivElem = document.querySelector('.blocklyDropDownDiv');
assert.strictEqual(dropDownDivElem.style.opacity, '0');
});
}); });
suite('showPositionedByBlock()', function () { suite('showPositionedByBlock()', function () {
@@ -353,48 +325,6 @@ suite('DropDownDiv', function () {
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block);
assert.strictEqual(document.activeElement, dropDownDivElem); assert.strictEqual(document.activeElement, dropDownDivElem);
}); });
test('without auto close on lost focus lost focus does not hide 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,
false,
);
// Focus an element outside of the drop-down.
document.getElementById('nonTreeElementForEphemeralFocus').focus();
// Even though the drop-down lost focus, it should still be visible.
const dropDownDivElem = document.querySelector('.blocklyDropDownDiv');
assert.strictEqual(dropDownDivElem.style.opacity, '1');
});
test('with auto close on lost focus lost focus hides 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,
true,
);
// Focus an element outside of the drop-down.
document.getElementById('nonTreeElementForEphemeralFocus').focus();
// the drop-down should now be hidden since it lost focus.
const dropDownDivElem = document.querySelector('.blocklyDropDownDiv');
assert.strictEqual(dropDownDivElem.style.opacity, '0');
});
}); });
suite('hideWithoutAnimation()', function () { suite('hideWithoutAnimation()', function () {

View File

@@ -5624,6 +5624,21 @@ suite('FocusManager', function () {
/* Ephemeral focus tests. */ /* Ephemeral focus tests. */
suite('takeEphemeralFocus()', function () { 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 () { test('with no focused node does not change states', function () {
this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableTree2);
this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableGroup2);
@@ -5960,176 +5975,5 @@ suite('FocusManager', function () {
); );
assert.strictEqual(document.activeElement, nodeElem); assert.strictEqual(document.activeElement, nodeElem);
}); });
test('with focus change callback initially calls focus change callback with initial state', function () {
const callback = sinon.fake();
this.focusManager.registerTree(this.testFocusableTree2);
this.focusManager.registerTree(this.testFocusableGroup2);
const ephemeralElement = document.getElementById(
'nonTreeElementForEphemeralFocus',
);
this.focusManager.takeEphemeralFocus(ephemeralElement, callback);
assert.strictEqual(callback.callCount, 1);
assert.isTrue(callback.firstCall.calledWithExactly(true));
});
test('with focus change callback finishes ephemeral does not calls focus change callback again', function () {
const callback = sinon.fake();
this.focusManager.registerTree(this.testFocusableTree2);
this.focusManager.registerTree(this.testFocusableGroup2);
const ephemeralElement = document.getElementById(
'nonTreeElementForEphemeralFocus',
);
const finishFocusCallback = this.focusManager.takeEphemeralFocus(
ephemeralElement,
callback,
);
callback.resetHistory();
finishFocusCallback();
assert.isFalse(callback.called);
});
test('with focus change callback set focus to ephemeral child does not call focus change callback again', function () {
const callback = sinon.fake();
this.focusManager.registerTree(this.testFocusableTree2);
this.focusManager.registerTree(this.testFocusableGroup2);
const ephemeralElement = document.getElementById(
'nonTreeElementForEphemeralFocus',
);
const ephemeralElementChild = document.getElementById(
'nonTreeElementForEphemeralFocus.child1',
);
this.focusManager.takeEphemeralFocus(ephemeralElement, callback);
callback.resetHistory();
ephemeralElementChild.focus();
// Focusing a child element shouldn't invoke the callback since the
// ephemeral element's tree still holds focus.
assert.isFalse(callback.called);
});
test('with focus change callback set focus to non-ephemeral element calls focus change callback', function () {
const callback = sinon.fake();
this.focusManager.registerTree(this.testFocusableTree2);
this.focusManager.registerTree(this.testFocusableGroup2);
const ephemeralElement = document.getElementById(
'nonTreeElementForEphemeralFocus',
);
const ephemeralElement2 = document.getElementById(
'nonTreeElementForEphemeralFocus2',
);
this.focusManager.takeEphemeralFocus(ephemeralElement, callback);
ephemeralElement2.focus();
// There should be a second call that indicates focus was lost.
assert.strictEqual(callback.callCount, 2);
assert.isTrue(callback.secondCall.calledWithExactly(false));
});
test('with focus change callback set focus to non-ephemeral element then back calls focus change callback again', function () {
const callback = sinon.fake();
this.focusManager.registerTree(this.testFocusableTree2);
this.focusManager.registerTree(this.testFocusableGroup2);
const ephemeralElement = document.getElementById(
'nonTreeElementForEphemeralFocus',
);
const ephemeralElementChild = document.getElementById(
'nonTreeElementForEphemeralFocus.child1',
);
const ephemeralElement2 = document.getElementById(
'nonTreeElementForEphemeralFocus2',
);
this.focusManager.takeEphemeralFocus(ephemeralElement, callback);
ephemeralElement2.focus();
ephemeralElementChild.focus();
// The latest call should be returning focus.
assert.strictEqual(callback.callCount, 3);
assert.isTrue(callback.thirdCall.calledWithExactly(true));
});
test('with focus change callback set focus to non-ephemeral element with auto return finishes ephemeral does not restore to focused node', function () {
this.focusManager.registerTree(this.testFocusableTree2);
this.focusManager.registerTree(this.testFocusableGroup2);
this.focusManager.focusNode(this.testFocusableTree2Node1);
const ephemeralElement = document.getElementById(
'nonTreeGroupForEphemeralFocus',
);
const ephemeralElement2 = document.getElementById(
'nonTreeElementForEphemeralFocus2',
);
const finishFocusCallback = this.focusManager.takeEphemeralFocus(
ephemeralElement,
(hasFocus) => {
if (!hasFocus) finishFocusCallback();
},
);
// Force focus away, triggering the callback's automatic returning logic.
ephemeralElement2.focus();
// The original node should not be focused since the ephemeral element
// lost its own DOM focus while ephemeral focus was active. Instead, the
// newly active element should still hold focus.
const activeElems = Array.from(
document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR),
);
const passiveElems = Array.from(
document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR),
);
assert.isEmpty(activeElems);
assert.strictEqual(passiveElems.length, 1);
assert.includesClass(
this.testFocusableTree2Node1.getFocusableElement().classList,
FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME,
);
assert.isNull(this.focusManager.getFocusedNode());
assert.strictEqual(document.activeElement, ephemeralElement2);
assert.isFalse(this.focusManager.ephemeralFocusTaken());
});
test('with focus on non-ephemeral element ephemeral ended does not restore to focused node', function () {
this.focusManager.registerTree(this.testFocusableTree2);
this.focusManager.registerTree(this.testFocusableGroup2);
this.focusManager.focusNode(this.testFocusableTree2Node1);
const ephemeralElement = document.getElementById(
'nonTreeGroupForEphemeralFocus',
);
const ephemeralElement2 = document.getElementById(
'nonTreeElementForEphemeralFocus2',
);
const finishFocusCallback =
this.focusManager.takeEphemeralFocus(ephemeralElement);
// Force focus away, triggering the callback's automatic returning logic.
ephemeralElement2.focus();
finishFocusCallback();
// The original node should not be focused since the ephemeral element
// lost its own DOM focus while ephemeral focus was active. Instead, the
// newly active element should still hold focus.
const activeElems = Array.from(
document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR),
);
const passiveElems = Array.from(
document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR),
);
assert.isEmpty(activeElems);
assert.strictEqual(passiveElems.length, 1);
assert.includesClass(
this.testFocusableTree2Node1.getFocusableElement().classList,
FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME,
);
assert.isNull(this.focusManager.getFocusedNode());
assert.strictEqual(document.activeElement, ephemeralElement2);
assert.isFalse(this.focusManager.ephemeralFocusTaken());
});
}); });
}); });

View File

@@ -94,13 +94,7 @@
</div> </div>
</div> </div>
<div id="testUnfocusableElement">Unfocusable element</div> <div id="testUnfocusableElement">Unfocusable element</div>
<div id="nonTreeElementForEphemeralFocus" tabindex="-1"> <div id="nonTreeElementForEphemeralFocus" />
<div
id="nonTreeElementForEphemeralFocus.child1"
tabindex="-1"
style="margin-left: 1em"></div>
</div>
<div id="nonTreeElementForEphemeralFocus2" tabindex="-1"></div>
<svg width="250" height="250"> <svg width="250" height="250">
<g id="testFocusableGroup1"> <g id="testFocusableGroup1">
<g id="testFocusableGroup1.node1"> <g id="testFocusableGroup1.node1">
@@ -142,7 +136,7 @@
</text> </text>
</g> </g>
</g> </g>
<g id="nonTreeGroupForEphemeralFocus" tabindex="-1"></g> <g id="nonTreeGroupForEphemeralFocus"></g>
</svg> </svg>
<!-- Load mocha et al. before Blockly and the test modules so that <!-- Load mocha et al. before Blockly and the test modules so that
we can safely import the test modules that make calls we can safely import the test modules that make calls

View File

@@ -423,92 +423,5 @@ suite('WidgetDiv', function () {
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block);
assert.strictEqual(document.activeElement, blockFocusableElem); 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);
});
test('without auto close on lost focus lost focus does not hide 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, false);
// Focus an element outside of the widget.
document.getElementById('nonTreeElementForEphemeralFocus').focus();
// Even though the widget lost focus, it should still be visible.
const widgetDivElem = document.querySelector('.blocklyWidgetDiv');
assert.strictEqual(widgetDivElem.style.display, 'block');
});
test('with auto close on lost focus lost focus hides 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, true);
// Focus an element outside of the widget.
document.getElementById('nonTreeElementForEphemeralFocus').focus();
// The widget should now be hidden since it lost focus.
const widgetDivElem = document.querySelector('.blocklyWidgetDiv');
assert.strictEqual(widgetDivElem.style.display, 'none');
});
test('with auto close on lost focus lost focus with nested div hides widget div', 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, true);
nestedDiv.focus(); // It's valid to focus this during ephemeral focus.
// Focus an element outside of the widget.
document.getElementById('nonTreeElementForEphemeralFocus').focus();
// The widget should now be hidden since it lost focus.
const widgetDivElem = document.querySelector('.blocklyWidgetDiv');
assert.strictEqual(widgetDivElem.style.display, 'none');
});
test('with auto close on lost focus lost focus with nested div does not restore 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, true);
nestedDiv.focus(); // It's valid to focus this during ephemeral focus.
// Focus an element outside of the widget.
const elem = document.getElementById('nonTreeElementForEphemeralFocus');
elem.focus();
// Auto hiding should not restore focus back to the block since ephemeral
// focus was lost before it was returned.
assert.isNull(Blockly.getFocusManager().getFocusedNode());
assert.strictEqual(document.activeElement, elem);
});
}); });
}); });