Skip to content

Commit 412270f

Browse files
tlouisseKnorgias
andcommitted
fix(overlays): enhanced and documented closeOnOutsideClick
Co-authored-by: Konstantinos Norgias <[email protected]>
1 parent 3aa4783 commit 412270f

File tree

9 files changed

+158
-65
lines changed

9 files changed

+158
-65
lines changed

.changeset/forty-taxis-lay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@lion/overlays': patch
3+
---
4+
5+
fix: only close an overlay if both mousedown and mousep events are outside (for hidesOnOutsideClick)

docs/docs/systems/overlays/configuration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ export const hidesOnOutsideClick = () => {
167167
<demo-overlay-system .config=${hidesOnOutsideClickConfig}>
168168
<button slot="invoker">Click me to open the overlay!</button>
169169
<div slot="content" class="demo-overlay">
170-
Hello! You can close this notification here:
170+
<label for="myInput">Clicking this label should not trigger close</label>
171+
<input id="myInput" />
171172
<button
172173
class="close-button"
173174
@click=${e => e.target.dispatchEvent(new Event('close-overlay', { bubbles: true }))}

packages/input-datepicker/test/lion-input-datepicker.test.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { LionCalendar, isSameDate } from '@lion/calendar';
22
import { html, LitElement } from '@lion/core';
33
import { IsDateDisabled, MaxDate, MinDate, MinMaxDate } from '@lion/form-core';
44
import { aTimeout, defineCE, expect, fixture as _fixture, nextFrame } from '@open-wc/testing';
5+
import { mimicClick } from '@lion/overlays/test-helpers';
56
import sinon from 'sinon';
67
import { setViewport } from '@web/test-runner-commands';
78
import '@lion/input-datepicker/define';
@@ -98,13 +99,13 @@ describe('<lion-input-datepicker>', () => {
9899
expect(elObj.overlayController.isShown).to.equal(false);
99100
});
100101

101-
it('closes the calendar via outside click', async () => {
102+
it('closes the calendar via outside click event', async () => {
102103
const el = await fixture(html`<lion-input-datepicker></lion-input-datepicker>`);
103104
const elObj = new DatepickerInputObject(el);
104105
await elObj.openCalendar();
105106
expect(elObj.overlayController.isShown).to.equal(true);
106107

107-
document.body.click();
108+
mimicClick(document.body);
108109
await aTimeout(0);
109110
expect(elObj.overlayController.isShown).to.be.false;
110111
});

packages/overlays/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
".": "./index.js",
5454
"./test-suites": "./test-suites/index.js",
5555
"./translations/*": "./translations/*",
56+
"./test-helpers": "./test-helpers.js",
5657
"./docs/": "./docs/"
5758
}
5859
}

packages/overlays/src/OverlayController.js

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,55 +1124,86 @@ export class OverlayController extends EventTargetShim {
11241124
const addOrRemoveListener = phase === 'show' ? 'addEventListener' : 'removeEventListener';
11251125

11261126
if (phase === 'show') {
1127-
let wasClickInside = false;
1128-
let wasIndirectSynchronousClick = false;
1129-
// Handle on capture phase and remember till the next task that there was an inside click
1127+
/**
1128+
* We listen to click (more specifically mouseup and mousedown) events
1129+
* in their capture phase (see our tests about 3rd parties stopping event propagation).
1130+
* We define an outside click as follows:
1131+
* - both mousedown and mouseup occur outside of content or invoker
1132+
*
1133+
* This means we have the following flow:
1134+
* [1]. (optional) mousedown is triggered on content/invoker
1135+
* [2]. mouseup is triggered on document (logic will be scheduled to step 4)
1136+
* [3]. (optional) mouseup is triggered on content/invoker
1137+
* [4]. mouseup logic is executed on document (its logic is inside a timeout and is thus
1138+
* executed after 3)
1139+
* [5]. Reset all helper variables that were considered in step [4]
1140+
*
1141+
*/
1142+
1143+
/** @type {boolean} */
1144+
let wasMouseDownInside = false;
1145+
/** @type {boolean} */
1146+
let wasMouseUpInside = false;
1147+
11301148
/** @type {EventListenerOrEventListenerObject} */
1131-
this.__preventCloseOutsideClick = () => {
1132-
if (wasClickInside) {
1133-
// This occurs when a synchronous new click is triggered from a previous click.
1134-
// For instance, when we have a label pointing to an input, the platform triggers
1135-
// a new click on the input. Not taking this click into account, will hide the overlay
1136-
// in `__onCaptureHtmlClick`
1137-
wasIndirectSynchronousClick = true;
1138-
}
1139-
wasClickInside = true;
1140-
setTimeout(() => {
1141-
wasClickInside = false;
1142-
setTimeout(() => {
1143-
wasIndirectSynchronousClick = false;
1144-
});
1145-
});
1149+
this.__onInsideMouseDown = () => {
1150+
// [1]. was mousedown inside content or invoker
1151+
wasMouseDownInside = true;
1152+
};
1153+
1154+
this.__onInsideMouseUp = () => {
1155+
// [3]. was mouseup inside content or invoker
1156+
wasMouseUpInside = true;
11461157
};
1147-
// handle on capture phase and schedule the hide if needed
1158+
11481159
/** @type {EventListenerOrEventListenerObject} */
1149-
this.__onCaptureHtmlClick = () => {
1160+
this.__onDocumentMouseUp = () => {
1161+
// [2]. The captured mouseup goes from top of the document to bottom. We add a timeout,
1162+
// so that [3] can be executed before [4]
11501163
setTimeout(() => {
1151-
if (wasClickInside === false && !wasIndirectSynchronousClick) {
1164+
// [4]. Keep open if step 1 (mousedown) or 3 (mouseup) was inside
1165+
if (!wasMouseDownInside && !wasMouseUpInside) {
11521166
this.hide();
11531167
}
1168+
// [5]. Reset...
1169+
wasMouseDownInside = false;
1170+
wasMouseUpInside = false;
11541171
});
11551172
};
11561173
}
11571174

11581175
this.contentWrapperNode[addOrRemoveListener](
1159-
'click',
1176+
'mousedown',
1177+
/** @type {EventListenerOrEventListenerObject} */
1178+
(this.__onInsideMouseDown),
1179+
true,
1180+
);
1181+
this.contentWrapperNode[addOrRemoveListener](
1182+
'mouseup',
11601183
/** @type {EventListenerOrEventListenerObject} */
1161-
(this.__preventCloseOutsideClick),
1184+
(this.__onInsideMouseUp),
11621185
true,
11631186
);
11641187
if (this.invokerNode) {
1188+
// An invoker click (usually resulting in toggle) should be left to a different part of
1189+
// the code
1190+
this.invokerNode[addOrRemoveListener](
1191+
'mousedown',
1192+
/** @type {EventListenerOrEventListenerObject} */
1193+
(this.__onInsideMouseDown),
1194+
true,
1195+
);
11651196
this.invokerNode[addOrRemoveListener](
1166-
'click',
1197+
'mouseup',
11671198
/** @type {EventListenerOrEventListenerObject} */
1168-
(this.__preventCloseOutsideClick),
1199+
(this.__onInsideMouseUp),
11691200
true,
11701201
);
11711202
}
11721203
document.documentElement[addOrRemoveListener](
1173-
'click',
1204+
'mouseup',
11741205
/** @type {EventListenerOrEventListenerObject} */
1175-
(this.__onCaptureHtmlClick),
1206+
(this.__onDocumentMouseUp),
11761207
true,
11771208
);
11781209
}

packages/overlays/test-helpers.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { mimicClick } from './test-helpers/mimicClick.js';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
async function sleep(t = 0) {
2+
return new Promise(resolve => {
3+
setTimeout(() => {
4+
resolve(true);
5+
}, t);
6+
});
7+
}
8+
9+
/**
10+
* @param {HTMLElement} el
11+
* @param {{isAsync?:boolean, releaseElement?: HTMLElement}} [config]
12+
*/
13+
export async function mimicClick(el, { isAsync, releaseElement } = { isAsync: false }) {
14+
const releaseEl = releaseElement || el;
15+
el.dispatchEvent(new MouseEvent('mousedown'));
16+
if (isAsync) {
17+
await sleep();
18+
}
19+
releaseEl.dispatchEvent(new MouseEvent('click'));
20+
releaseEl.dispatchEvent(new MouseEvent('mouseup'));
21+
}

packages/overlays/test/OverlayController.test.js

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { OverlayController } from '../src/OverlayController.js';
88
import { overlays } from '../src/overlays.js';
99
import { keyCodes } from '../src/utils/key-codes.js';
1010
import { simulateTab } from '../src/utils/simulate-tab.js';
11+
import { mimicClick } from '../test-helpers.js';
1112

1213
/**
1314
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
@@ -451,8 +452,12 @@ describe('OverlayController', () => {
451452
contentNode,
452453
});
453454
await ctrl.show();
455+
mimicClick(document.body);
456+
await aTimeout(0);
457+
expect(ctrl.isShown).to.be.false;
454458

455-
document.body.click();
459+
await ctrl.show();
460+
await mimicClick(document.body, { isAsync: true });
456461
await aTimeout(0);
457462
expect(ctrl.isShown).to.be.false;
458463
});
@@ -479,13 +484,56 @@ describe('OverlayController', () => {
479484

480485
expect(ctrl.isShown).to.be.true;
481486

487+
// Don't hide on inside mousedown & outside mouseup
488+
ctrl.contentNode.dispatchEvent(new MouseEvent('mousedown'));
489+
await aTimeout(0);
490+
document.body.dispatchEvent(new MouseEvent('mouseup'));
491+
await aTimeout(0);
492+
expect(ctrl.isShown).to.be.true;
493+
482494
// Important to check if it can be still shown after, because we do some hacks inside
483495
await ctrl.hide();
484496
expect(ctrl.isShown).to.be.false;
485497
await ctrl.show();
486498
expect(ctrl.isShown).to.be.true;
487499
});
488500

501+
it('only hides when both mousedown and mouseup events are outside', async () => {
502+
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
503+
const ctrl = new OverlayController({
504+
...withGlobalTestConfig(),
505+
hidesOnOutsideClick: true,
506+
contentNode,
507+
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
508+
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
509+
`)),
510+
});
511+
await ctrl.show();
512+
mimicClick(document.body, { releaseElement: contentNode });
513+
await aTimeout(0);
514+
expect(ctrl.isShown).to.be.true;
515+
516+
mimicClick(contentNode, { releaseElement: document.body });
517+
await aTimeout(0);
518+
expect(ctrl.isShown).to.be.true;
519+
520+
mimicClick(document.body, {
521+
releaseElement: /** @type {HTMLElement} */ (ctrl.invokerNode),
522+
});
523+
await aTimeout(0);
524+
expect(ctrl.isShown).to.be.true;
525+
526+
mimicClick(/** @type {HTMLElement} */ (ctrl.invokerNode), {
527+
releaseElement: document.body,
528+
});
529+
await aTimeout(0);
530+
expect(ctrl.isShown).to.be.true;
531+
532+
mimicClick(document.body);
533+
await aTimeout(0);
534+
expect(ctrl.isShown).to.be.false;
535+
});
536+
489537
it('doesn\'t hide on "inside sub shadow dom" click', async () => {
490538
const invokerNode = /** @type {HTMLElement} */ (await fixture('<button>Invoker</button>'));
491539
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
@@ -548,31 +596,24 @@ describe('OverlayController', () => {
548596
contentNode,
549597
invokerNode,
550598
});
599+
const stopProp = (/** @type {Event} */ e) => e.stopPropagation();
551600
const dom = await fixture(
552-
/**
553-
* @param {{ stopPropagation: () => any; }} e
554-
*/
555601
`
556602
<div>
557603
<div id="popup">${invokerNode}${contentNode}</div>
558-
<div
559-
id="regular-sibling"
560-
@click="${() => {
561-
/* propagates */
562-
}}"
563-
></div>
564-
<third-party-noise @click="${(/** @type {Event} */ e) => e.stopPropagation()}">
604+
<div id="third-party-noise" @click="${stopProp}" @mousedown="${stopProp}" @mouseup="${stopProp}">
565605
This element prevents our handlers from reaching the document click handler.
566-
</third-party-noise>
606+
</div>
567607
</div>
568608
`,
569609
);
570610

571611
await ctrl.show();
572612
expect(ctrl.isShown).to.equal(true);
573613

574-
/** @type {HTMLElement} */
575-
(dom.querySelector('third-party-noise')).click();
614+
const noiseEl = /** @type {HTMLElement} */ (dom.querySelector('#third-party-noise'));
615+
616+
mimicClick(noiseEl);
576617
await aTimeout(0);
577618
expect(ctrl.isShown).to.equal(false);
578619

@@ -592,35 +633,26 @@ describe('OverlayController', () => {
592633
contentNode,
593634
invokerNode,
594635
});
636+
const stopProp = (/** @type {Event} */ e) => e.stopPropagation();
595637
const dom = /** @type {HTMLElement} */ (await fixture(`
596638
<div>
597639
<div id="popup">${invokerNode}${ctrl.content}</div>
598-
<div
599-
id="regular-sibling"
600-
@click="${() => {
601-
/* propagates */
602-
}}"
603-
></div>
604-
<third-party-noise>
640+
<div id="third-party-noise">
605641
This element prevents our handlers from reaching the document click handler.
606-
</third-party-noise>
642+
</div>
607643
</div>
608644
`));
609645

610-
/** @type {HTMLElement} */
611-
(dom.querySelector('third-party-noise')).addEventListener(
612-
'click',
613-
(/** @type {Event} */ event) => {
614-
event.stopPropagation();
615-
},
616-
true,
617-
);
646+
const noiseEl = /** @type {HTMLElement} */ (dom.querySelector('#third-party-noise'));
647+
648+
noiseEl.addEventListener('click', stopProp, true);
649+
noiseEl.addEventListener('mousedown', stopProp, true);
650+
noiseEl.addEventListener('mouseup', stopProp, true);
618651

619652
await ctrl.show();
620653
expect(ctrl.isShown).to.equal(true);
621654

622-
/** @type {HTMLElement} */
623-
(dom.querySelector('third-party-noise')).click();
655+
mimicClick(noiseEl);
624656
await aTimeout(0);
625657
expect(ctrl.isShown).to.equal(false);
626658

packages/select-rich/test/lion-select-rich.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { LitElement } from '@lion/core';
22
import { renderLitAsNode } from '@lion/helpers';
33
import { OverlayController } from '@lion/overlays';
44
import { LionOption } from '@lion/listbox';
5+
import { mimicClick } from '@lion/overlays/test-helpers';
56
import {
67
aTimeout,
78
defineCE,
@@ -253,9 +254,8 @@ describe('lion-select-rich', () => {
253254
));
254255

255256
expect(el.opened).to.be.true;
256-
// a click on the button will trigger hide on outside click
257-
// which we then need to sync back to "opened"
258-
outerEl.click();
257+
258+
mimicClick(outerEl);
259259
await aTimeout(0);
260260
expect(el.opened).to.be.false;
261261
});

0 commit comments

Comments
 (0)