Skip to content

Commit 524693f

Browse files
committed
chore: Add tests and improve + fix documentation.
1 parent 0fb39d2 commit 524693f

5 files changed

Lines changed: 245 additions & 10 deletions

File tree

core/dropdowndiv.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ export function setColour(backgroundColour: string, borderColour: string) {
213213
* passed in here then callers should manage ephemeral focus directly
214214
* otherwise focus may not properly restore when the widget closes. Defaults
215215
* to true.
216-
* @param manageEphemeralFocus Whether the drop-down should automatically hide
216+
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
217217
* if it loses DOM focus for any reason.
218218
* @returns True if the menu rendered below block; false if above.
219219
*/
@@ -249,7 +249,7 @@ export function showPositionedByBlock<T>(
249249
* passed in here then callers should manage ephemeral focus directly
250250
* otherwise focus may not properly restore when the widget closes. Defaults
251251
* to true.
252-
* @param manageEphemeralFocus Whether the drop-down should automatically hide
252+
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
253253
* if it loses DOM focus for any reason.
254254
* @returns True if the menu rendered below block; false if above.
255255
*/
@@ -310,7 +310,7 @@ function getScaledBboxOfField(field: Field): Rect {
310310
* according to the drop-down div's lifetime. Note that if a false value is
311311
* passed in here then callers should manage ephemeral focus directly
312312
* otherwise focus may not properly restore when the widget closes.
313-
* @param manageEphemeralFocus Whether the drop-down should automatically hide
313+
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
314314
* if it loses DOM focus for any reason.
315315
* @returns True if the menu rendered below block; false if above.
316316
*/
@@ -369,7 +369,7 @@ function showPositionedByRect(
369369
* @param opt_onHide Optional callback for when the drop-down is hidden.
370370
* @param manageEphemeralFocus Whether ephemeral focus should be managed
371371
* according to the widget div's lifetime.
372-
* @param manageEphemeralFocus Whether the drop-down should automatically hide
372+
* @param autoCloseOnLostFocus Whether the drop-down should automatically hide
373373
* if it loses DOM focus for any reason.
374374
* @returns True if the menu rendered at the primary origin point.
375375
* @internal

core/focus_manager.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
1717
*/
1818
export type ReturnEphemeralFocus = () => void;
1919

20+
/**
21+
* Type declaration for an optional callback to observe when an element with
22+
* ephemeral focus has its DOM focus changed before ephemeral focus is returned.
23+
*
24+
* See FocusManager.takeEphemeralFocus for more details.
25+
*/
2026
export type EphemeralFocusChangedInDom = (hasDomFocus: boolean) => void;
2127

2228
/**
@@ -81,7 +87,7 @@ export class FocusManager {
8187
private registeredTrees: Array<TreeRegistration> = [];
8288

8389
private ephemerallyFocusedElement: HTMLElement | SVGElement | null = null;
84-
private ephemeralFocusChangedInDomCallback: EphemeralFocusChangedInDom | null =
90+
private ephemeralDomFocusChangedCallback: EphemeralFocusChangedInDom | null =
8591
null;
8692
private ephemerallyFocusedElementCurrentlyHasFocus: boolean = false;
8793
private lockFocusStateChanges: boolean = false;
@@ -125,14 +131,14 @@ export class FocusManager {
125131
}
126132

127133
const ephemeralFocusElem = this.ephemerallyFocusedElement;
128-
if (ephemeralFocusElem && this.ephemeralFocusChangedInDomCallback) {
134+
if (ephemeralFocusElem && this.ephemeralDomFocusChangedCallback) {
129135
const hadFocus = this.ephemerallyFocusedElementCurrentlyHasFocus;
130136
const hasFocus =
131137
!!element &&
132138
element instanceof Node &&
133139
ephemeralFocusElem.contains(element);
134140
if (hadFocus !== hasFocus) {
135-
this.ephemeralFocusChangedInDomCallback(hasFocus);
141+
this.ephemeralDomFocusChangedCallback(hasFocus);
136142
this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus;
137143
}
138144
}
@@ -441,6 +447,28 @@ export class FocusManager {
441447
* the returned lambda is called. Additionally, only 1 ephemeral focus context
442448
* can be active at any given time (attempting to activate more than one
443449
* simultaneously will result in an error being thrown).
450+
*
451+
* Some notes about the ephemeral focus tracking callback:
452+
* - This method will be called initially with a value of 'true' indicating
453+
* that the ephemeral element has been focused, so callers can rely on that,
454+
* if needed, for initialization logic.
455+
* - It's safe to end ephemeral focus in this callback (and is encouraged for
456+
* callers that wish to automatically end ephemeral focus when the user
457+
* directs focus outside of the element).
458+
* - The element AND all of its descendants are tracked for focus. That means
459+
* the callback will ONLY be called with a value of 'false' if focus
460+
* completely leaves the DOM tree for the provided focusable element.
461+
* - It's invalid to return focus on the very first call to the callback,
462+
* however this is expected to be impossible, anyway, since this method
463+
* won't return until after the first call to the callback (thus there will
464+
* be no means to return ephemeral focus).
465+
*
466+
* @param focusableElement The element that should be focused until returned.
467+
* @param onFocusChangedInDom An optional callback which will be notified
468+
* whenever the provided element's focus changes before ephemeral focus is
469+
* returned. See the details above for specifics.
470+
* @returns A ReturnEphemeralFocus that must be called when ephemeral focus
471+
* should end.
444472
*/
445473
takeEphemeralFocus(
446474
focusableElement: HTMLElement | SVGElement,
@@ -454,7 +482,7 @@ export class FocusManager {
454482
);
455483
}
456484
this.ephemerallyFocusedElement = focusableElement;
457-
this.ephemeralFocusChangedInDomCallback = onFocusChangedInDom;
485+
this.ephemeralDomFocusChangedCallback = onFocusChangedInDom;
458486

459487
if (this.focusedNode) {
460488
this.passivelyFocusNode(this.focusedNode, null);
@@ -472,7 +500,7 @@ export class FocusManager {
472500
}
473501
hasFinishedEphemeralFocus = true;
474502
this.ephemerallyFocusedElement = null;
475-
this.ephemeralFocusChangedInDomCallback = null;
503+
this.ephemeralDomFocusChangedCallback = null;
476504
this.ephemerallyFocusedElementCurrentlyHasFocus = false;
477505

478506
if (this.focusedNode) {

tests/mocha/dropdowndiv_test.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,34 @@ suite('DropDownDiv', function () {
252252
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block);
253253
assert.strictEqual(document.activeElement, dropDownDivElem);
254254
});
255+
256+
test('without auto close on lost focus lost focus does not hide drop-down div', function () {
257+
const block = this.setUpBlockWithField();
258+
const field = Array.from(block.getFields())[0];
259+
Blockly.getFocusManager().focusNode(block);
260+
Blockly.DropDownDiv.showPositionedByField(field, null, null, true, false);
261+
262+
// Focus an element outside of the drop-down.
263+
document.getElementById('nonTreeElementForEphemeralFocus').focus();
264+
265+
// Even though the drop-down lost focus, it should still be visible.
266+
const dropDownDivElem = document.querySelector('.blocklyDropDownDiv');
267+
assert.strictEqual(dropDownDivElem.style.opacity, '1');
268+
});
269+
270+
test('with auto close on lost focus lost focus hides drop-down div', function () {
271+
const block = this.setUpBlockWithField();
272+
const field = Array.from(block.getFields())[0];
273+
Blockly.getFocusManager().focusNode(block);
274+
Blockly.DropDownDiv.showPositionedByField(field, null, null, true, true);
275+
276+
// Focus an element outside of the drop-down.
277+
document.getElementById('nonTreeElementForEphemeralFocus').focus();
278+
279+
// the drop-down should now be hidden since it lost focus.
280+
const dropDownDivElem = document.querySelector('.blocklyDropDownDiv');
281+
assert.strictEqual(dropDownDivElem.style.opacity, '0');
282+
});
255283
});
256284

257285
suite('showPositionedByBlock()', function () {
@@ -325,6 +353,48 @@ suite('DropDownDiv', function () {
325353
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block);
326354
assert.strictEqual(document.activeElement, dropDownDivElem);
327355
});
356+
357+
test('without auto close on lost focus lost focus does not hide drop-down div', function () {
358+
const block = this.setUpBlockWithField();
359+
const field = Array.from(block.getFields())[0];
360+
Blockly.getFocusManager().focusNode(block);
361+
Blockly.DropDownDiv.showPositionedByBlock(
362+
field,
363+
block,
364+
null,
365+
null,
366+
true,
367+
false,
368+
);
369+
370+
// Focus an element outside of the drop-down.
371+
document.getElementById('nonTreeElementForEphemeralFocus').focus();
372+
373+
// Even though the drop-down lost focus, it should still be visible.
374+
const dropDownDivElem = document.querySelector('.blocklyDropDownDiv');
375+
assert.strictEqual(dropDownDivElem.style.opacity, '1');
376+
});
377+
378+
test('with auto close on lost focus lost focus hides drop-down div', function () {
379+
const block = this.setUpBlockWithField();
380+
const field = Array.from(block.getFields())[0];
381+
Blockly.getFocusManager().focusNode(block);
382+
Blockly.DropDownDiv.showPositionedByBlock(
383+
field,
384+
block,
385+
null,
386+
null,
387+
true,
388+
true,
389+
);
390+
391+
// Focus an element outside of the drop-down.
392+
document.getElementById('nonTreeElementForEphemeralFocus').focus();
393+
394+
// the drop-down should now be hidden since it lost focus.
395+
const dropDownDivElem = document.querySelector('.blocklyDropDownDiv');
396+
assert.strictEqual(dropDownDivElem.style.opacity, '0');
397+
});
328398
});
329399

330400
suite('hideWithoutAnimation()', function () {

tests/mocha/focus_manager_test.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5975,5 +5975,136 @@ suite('FocusManager', function () {
59755975
);
59765976
assert.strictEqual(document.activeElement, nodeElem);
59775977
});
5978+
5979+
test('with focus change callback initially calls focus change callback with initial state', function () {
5980+
const callback = sinon.fake();
5981+
this.focusManager.registerTree(this.testFocusableTree2);
5982+
this.focusManager.registerTree(this.testFocusableGroup2);
5983+
const ephemeralElement = document.getElementById(
5984+
'nonTreeElementForEphemeralFocus',
5985+
);
5986+
5987+
this.focusManager.takeEphemeralFocus(ephemeralElement, callback);
5988+
5989+
assert.strictEqual(callback.callCount, 1);
5990+
assert.isTrue(callback.firstCall.calledWithExactly(true));
5991+
});
5992+
5993+
test('with focus change callback finishes ephemeral does not calls focus change callback again', function () {
5994+
const callback = sinon.fake();
5995+
this.focusManager.registerTree(this.testFocusableTree2);
5996+
this.focusManager.registerTree(this.testFocusableGroup2);
5997+
const ephemeralElement = document.getElementById(
5998+
'nonTreeElementForEphemeralFocus',
5999+
);
6000+
const finishFocusCallback = this.focusManager.takeEphemeralFocus(
6001+
ephemeralElement,
6002+
callback,
6003+
);
6004+
callback.resetHistory();
6005+
6006+
finishFocusCallback();
6007+
6008+
assert.isFalse(callback.called);
6009+
});
6010+
6011+
test('with focus change callback set focus to ephemeral child does not call focus change callback again', function () {
6012+
const callback = sinon.fake();
6013+
this.focusManager.registerTree(this.testFocusableTree2);
6014+
this.focusManager.registerTree(this.testFocusableGroup2);
6015+
const ephemeralElement = document.getElementById(
6016+
'nonTreeElementForEphemeralFocus',
6017+
);
6018+
const ephemeralElementChild = document.getElementById(
6019+
'nonTreeElementForEphemeralFocus.child1',
6020+
);
6021+
this.focusManager.takeEphemeralFocus(ephemeralElement, callback);
6022+
callback.resetHistory();
6023+
6024+
ephemeralElementChild.focus();
6025+
6026+
// Focusing a child element shouldn't invoke the callback since the
6027+
// ephemeral element's tree still holds focus.
6028+
assert.isFalse(callback.called);
6029+
});
6030+
6031+
test('with focus change callback set focus to non-ephemeral element calls focus change callback', function () {
6032+
const callback = sinon.fake();
6033+
this.focusManager.registerTree(this.testFocusableTree2);
6034+
this.focusManager.registerTree(this.testFocusableGroup2);
6035+
const ephemeralElement = document.getElementById(
6036+
'nonTreeElementForEphemeralFocus',
6037+
);
6038+
const ephemeralElement2 = document.getElementById(
6039+
'nonTreeElementForEphemeralFocus2',
6040+
);
6041+
this.focusManager.takeEphemeralFocus(ephemeralElement, callback);
6042+
6043+
ephemeralElement2.focus();
6044+
6045+
// There should be a second call that indicates focus was lost.
6046+
assert.strictEqual(callback.callCount, 2);
6047+
assert.isTrue(callback.secondCall.calledWithExactly(false));
6048+
});
6049+
6050+
test('with focus change callback set focus to non-ephemeral element then back calls focus change callback again', function () {
6051+
const callback = sinon.fake();
6052+
this.focusManager.registerTree(this.testFocusableTree2);
6053+
this.focusManager.registerTree(this.testFocusableGroup2);
6054+
const ephemeralElement = document.getElementById(
6055+
'nonTreeElementForEphemeralFocus',
6056+
);
6057+
const ephemeralElementChild = document.getElementById(
6058+
'nonTreeElementForEphemeralFocus.child1',
6059+
);
6060+
const ephemeralElement2 = document.getElementById(
6061+
'nonTreeElementForEphemeralFocus2',
6062+
);
6063+
this.focusManager.takeEphemeralFocus(ephemeralElement, callback);
6064+
ephemeralElement2.focus();
6065+
6066+
ephemeralElementChild.focus();
6067+
6068+
// The latest call should be returning focus.
6069+
assert.strictEqual(callback.callCount, 3);
6070+
assert.isTrue(callback.thirdCall.calledWithExactly(true));
6071+
});
6072+
6073+
test('with focus change callback set focus to non-ephemeral element with auto return finishes ephemeral', function () {
6074+
this.focusManager.registerTree(this.testFocusableTree2);
6075+
this.focusManager.registerTree(this.testFocusableGroup2);
6076+
this.focusManager.focusNode(this.testFocusableTree2Node1);
6077+
const ephemeralElement = document.getElementById(
6078+
'nonTreeGroupForEphemeralFocus',
6079+
);
6080+
const ephemeralElement2 = document.getElementById(
6081+
'nonTreeElementForEphemeralFocus2',
6082+
);
6083+
const finishFocusCallback = this.focusManager.takeEphemeralFocus(
6084+
ephemeralElement,
6085+
(hasFocus) => {
6086+
if (!hasFocus) finishFocusCallback();
6087+
},
6088+
);
6089+
6090+
// Force focus away, triggering the callback's automatic returning logic.
6091+
ephemeralElement2.focus();
6092+
6093+
// The original focused node should be restored.
6094+
const nodeElem = this.testFocusableTree2Node1.getFocusableElement();
6095+
const activeElems = Array.from(
6096+
document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR),
6097+
);
6098+
assert.strictEqual(
6099+
this.focusManager.getFocusedNode(),
6100+
this.testFocusableTree2Node1,
6101+
);
6102+
assert.strictEqual(activeElems.length, 1);
6103+
assert.includesClass(
6104+
nodeElem.classList,
6105+
FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME,
6106+
);
6107+
assert.strictEqual(document.activeElement, nodeElem);
6108+
});
59786109
});
59796110
});

tests/mocha/index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,13 @@
9494
</div>
9595
</div>
9696
<div id="testUnfocusableElement">Unfocusable element</div>
97-
<div id="nonTreeElementForEphemeralFocus" />
97+
<div id="nonTreeElementForEphemeralFocus" tabindex="-1">
98+
<div
99+
id="nonTreeElementForEphemeralFocus.child1"
100+
tabindex="-1"
101+
style="margin-left: 1em"></div>
102+
</div>
103+
<div id="nonTreeElementForEphemeralFocus2" tabindex="-1"></div>
98104
<svg width="250" height="250">
99105
<g id="testFocusableGroup1">
100106
<g id="testFocusableGroup1.node1">

0 commit comments

Comments
 (0)