Skip to content

Commit e3509b6

Browse files
fix: allow popovers to close when interacting with non-nested overlays (#10322) (#10654)
Co-authored-by: Serhii Kulykov <iamkulykov@gmail.com>
1 parent 3daf16e commit e3509b6

File tree

3 files changed

+175
-49
lines changed

3 files changed

+175
-49
lines changed

packages/overlay/src/vaadin-overlay-stack-mixin.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,25 @@ const getAttachedInstances = () =>
1313
.filter((el) => el instanceof HTMLElement && el._hasOverlayStackMixin && !el.hasAttribute('closing'))
1414
.sort((a, b) => a.__zIndex - b.__zIndex || 0);
1515

16+
/**
17+
* Returns true if all the instances on top of the overlay are nested overlays.
18+
* @private
19+
*/
20+
export const hasOnlyNestedOverlays = (overlay) => {
21+
const instances = getAttachedInstances();
22+
const next = instances[instances.indexOf(overlay) + 1];
23+
if (!next) {
24+
return true;
25+
}
26+
27+
// Check if the overlay contains the owner element of another overlay
28+
if (next.owner && !overlay._deepContains(next.owner)) {
29+
return false;
30+
}
31+
32+
return hasOnlyNestedOverlays(next);
33+
};
34+
1635
/**
1736
* Returns all attached overlay instances excluding notification container,
1837
* which only needs to be in the stack for zIndex but not pointer-events.

packages/popover/src/vaadin-popover.js

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
1717
import { OverlayClassMixin } from '@vaadin/component-base/src/overlay-class-mixin.js';
1818
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
1919
import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js';
20-
import { isLastOverlay as isLastOverlayBase } from '@vaadin/overlay/src/vaadin-overlay-stack-mixin.js';
20+
import {
21+
hasOnlyNestedOverlays,
22+
isLastOverlay as isLastOverlayBase,
23+
} from '@vaadin/overlay/src/vaadin-overlay-stack-mixin.js';
2124
import { ThemePropertyMixin } from '@vaadin/vaadin-themable-mixin/vaadin-theme-property-mixin.js';
2225
import { PopoverPositionMixin } from './vaadin-popover-position-mixin.js';
2326
import { PopoverTargetMixin } from './vaadin-popover-target-mixin.js';
@@ -753,10 +756,14 @@ class Popover extends PopoverPositionMixin(
753756

754757
/** @private */
755758
__onTargetFocusOut(event) {
756-
// Do not close the popover on overlay focusout if it's not the last one.
759+
// Do not close if there is a nested overlay that should be closed through some method first.
757760
// This covers the case when focus moves to the nested popover opened
758761
// without focusing parent popover overlay (e.g. using hover trigger).
759-
if (this._overlayElement.opened && !isLastOverlay(this._overlayElement)) {
762+
if (
763+
this._overlayElement.opened &&
764+
!isLastOverlay(this._overlayElement) &&
765+
hasOnlyNestedOverlays(this._overlayElement)
766+
) {
760767
return;
761768
}
762769

@@ -782,13 +789,16 @@ class Popover extends PopoverPositionMixin(
782789

783790
/** @private */
784791
__onTargetMouseLeave(event) {
785-
// Do not close the popover on target focusout if the overlay is not the last one.
786-
// This happens e.g. when opening the nested popover that uses non-modal overlay.
787-
if (this._overlayElement.opened && !isLastOverlay(this._overlayElement)) {
792+
// Do not close if the pointer moves to the overlay
793+
if (this._overlayElement.contains(event.relatedTarget)) {
788794
return;
789795
}
790-
791-
if (this._overlayElement.contains(event.relatedTarget)) {
796+
// Do not close if there is a nested overlay that should be closed through some method first.
797+
if (
798+
this._overlayElement.opened &&
799+
!isLastOverlay(this._overlayElement) &&
800+
hasOnlyNestedOverlays(this._overlayElement)
801+
) {
792802
return;
793803
}
794804

@@ -808,11 +818,11 @@ class Popover extends PopoverPositionMixin(
808818

809819
/** @private */
810820
__onOverlayFocusOut(event) {
811-
// Do not close the popover on overlay focusout if it's not the last one.
821+
// Do not close if there is a nested overlay that should be closed through some method first.
812822
// This covers the following cases of nested overlay based components:
813823
// 1. Moving focus to the nested overlay (e.g. vaadin-select, vaadin-menu-bar)
814824
// 2. Closing not focused nested overlay on outside (e.g. vaadin-combo-box)
815-
if (!isLastOverlay(this._overlayElement)) {
825+
if (!isLastOverlay(this._overlayElement) && hasOnlyNestedOverlays(this._overlayElement)) {
816826
return;
817827
}
818828

@@ -854,14 +864,12 @@ class Popover extends PopoverPositionMixin(
854864

855865
/** @private */
856866
__onOverlayMouseLeave(event) {
857-
// Do not close the popover on overlay focusout if it's not the last one.
858-
// This happens when opening the nested component that uses "modal" overlay
859-
// setting `pointer-events: none` on the body (combo-box, date-picker etc).
860-
if (!isLastOverlay(this._overlayElement)) {
867+
// Do not close if the pointer moves to the target
868+
if (event.relatedTarget === this.target) {
861869
return;
862870
}
863-
864-
if (event.relatedTarget === this.target) {
871+
// Do not close if there is a nested overlay that should be closed through some method first.
872+
if (!isLastOverlay(this._overlayElement) && hasOnlyNestedOverlays(this._overlayElement)) {
865873
return;
866874
}
867875

packages/popover/test/nested.test.js

Lines changed: 132 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Popover } from '../src/vaadin-popover.js';
66
import { mouseenter, mouseleave } from './helpers.js';
77

88
describe('nested popover', () => {
9-
let popover, target, secondPopover, secondTarget;
9+
let popover, target, nestedPopover, nestedTarget;
1010

1111
before(() => {
1212
Popover.setDefaultFocusDelay(0);
@@ -23,11 +23,11 @@ describe('nested popover', () => {
2323
return;
2424
}
2525
root.innerHTML = `
26-
<button id="second-target">Second target</button>
27-
<vaadin-popover for="second-target"></vaadin-popover>
26+
<button id="nested-target">Nested target</button>
27+
<vaadin-popover for="nested-target"></vaadin-popover>
2828
`;
29-
[secondTarget, secondPopover] = root.children;
30-
secondPopover.renderer = (root2) => {
29+
[nestedTarget, nestedPopover] = root.children;
30+
nestedPopover.renderer = (root2) => {
3131
root2.textContent = 'Nested';
3232
};
3333
};
@@ -40,45 +40,45 @@ describe('nested popover', () => {
4040
target.click();
4141
await nextRender();
4242

43-
// Open the second popover
44-
secondTarget.click();
43+
// Open the nested popover
44+
nestedTarget.click();
4545
await nextRender();
4646

4747
// Expect both popovers to be opened
4848
expect(popover.opened).to.be.true;
49-
expect(secondPopover.opened).to.be.true;
49+
expect(nestedPopover.opened).to.be.true;
5050
});
5151

5252
it('should close the topmost overlay on global Escape press', async () => {
5353
esc(document.body);
5454
await nextRender();
5555

56-
// Expect only the second popover to be closed
56+
// Expect only the nested popover to be closed
5757
expect(popover.opened).to.be.true;
58-
expect(secondPopover.opened).to.be.false;
58+
expect(nestedPopover.opened).to.be.false;
5959

6060
esc(document.body);
6161
await nextRender();
6262

6363
// Expect both popovers to be closed
6464
expect(popover.opened).to.be.false;
65-
expect(secondPopover.opened).to.be.false;
65+
expect(nestedPopover.opened).to.be.false;
6666
});
6767

6868
it('should close the topmost overlay on outside click', async () => {
6969
outsideClick();
7070
await nextRender();
7171

72-
// Expect only the second popover to be closed
72+
// Expect only the nested popover to be closed
7373
expect(popover.opened).to.be.true;
74-
expect(secondPopover.opened).to.be.false;
74+
expect(nestedPopover.opened).to.be.false;
7575

7676
outsideClick();
7777
await nextRender();
7878

7979
// Expect both popovers to be closed
8080
expect(popover.opened).to.be.false;
81-
expect(secondPopover.opened).to.be.false;
81+
expect(nestedPopover.opened).to.be.false;
8282
});
8383
});
8484

@@ -92,11 +92,11 @@ describe('nested popover', () => {
9292
target.focus();
9393
await nextRender();
9494

95-
secondPopover.modal = true;
96-
await nextUpdate(secondPopover);
95+
nestedPopover.modal = true;
96+
await nextUpdate(nestedPopover);
9797

9898
// Open programmatically so focus stays on target
99-
secondPopover.opened = true;
99+
nestedPopover.opened = true;
100100
await nextRender();
101101

102102
expect(popover.opened).to.be.true;
@@ -106,11 +106,11 @@ describe('nested popover', () => {
106106
target.focus();
107107
await nextRender();
108108

109-
secondPopover.modal = true;
110-
await nextUpdate(secondPopover);
109+
nestedPopover.modal = true;
110+
await nextUpdate(nestedPopover);
111111

112-
secondTarget.focus();
113-
secondTarget.click();
112+
nestedTarget.focus();
113+
nestedTarget.click();
114114
await nextRender();
115115

116116
expect(popover.opened).to.be.true;
@@ -120,8 +120,8 @@ describe('nested popover', () => {
120120
target.focus();
121121
await nextRender();
122122

123-
secondTarget.focus();
124-
secondTarget.click();
123+
nestedTarget.focus();
124+
nestedTarget.click();
125125
await nextRender();
126126

127127
outsideClick();
@@ -137,15 +137,15 @@ describe('nested popover', () => {
137137
await nextUpdate(popover);
138138
});
139139

140-
it('should not close when mouse leaves the target if popover is not the last one', async () => {
140+
it('should not close when mouse leaves the target to nested popover', async () => {
141141
mouseenter(target);
142142
await nextRender();
143143

144-
mouseleave(target, secondTarget);
145-
secondTarget.click();
144+
mouseleave(target, nestedTarget);
145+
nestedTarget.click();
146146
await nextRender();
147147

148-
mouseleave(secondTarget, target);
148+
mouseleave(nestedTarget, target);
149149

150150
mouseenter(target);
151151
mouseleave(target);
@@ -154,15 +154,15 @@ describe('nested popover', () => {
154154
expect(popover.opened).to.be.true;
155155
});
156156

157-
it('should not close when mouse leaves the overlay if popover is not the last one', async () => {
157+
it('should not close when mouse leaves the overlay to nested popover', async () => {
158158
mouseenter(target);
159159
await nextRender();
160160

161-
mouseleave(target, secondTarget);
162-
secondTarget.click();
161+
mouseleave(target, nestedTarget);
162+
nestedTarget.click();
163163
await nextRender();
164164

165-
mouseleave(secondTarget);
165+
mouseleave(nestedTarget);
166166
await nextUpdate(popover);
167167

168168
expect(popover.opened).to.be.true;
@@ -176,14 +176,113 @@ describe('nested popover', () => {
176176
await nextRender();
177177

178178
// Open the second popover
179-
secondTarget.click();
179+
nestedTarget.click();
180180
await nextRender();
181181
});
182182

183183
it('should bring to front nested overlay on parent overlay bringToFront()', () => {
184-
const spy = sinon.spy(secondPopover._overlayElement, 'bringToFront');
184+
const spy = sinon.spy(nestedPopover._overlayElement, 'bringToFront');
185185
popover._overlayElement.bringToFront();
186186
expect(spy).to.be.calledOnce;
187187
});
188188
});
189189
});
190+
191+
describe('not nested popover', () => {
192+
let popover, popoverContent, target, secondPopover, secondPopoverContent, secondTarget;
193+
194+
beforeEach(async () => {
195+
popover = fixtureSync('<vaadin-popover></vaadin-popover>');
196+
target = fixtureSync('<button>Target</button>');
197+
popover.target = target;
198+
popover.renderer = (root) => {
199+
root.innerHTML = '<button>Popover content</button>';
200+
popoverContent = root.firstElementChild;
201+
};
202+
203+
secondPopover = fixtureSync('<vaadin-popover></vaadin-popover>');
204+
secondTarget = fixtureSync('<button>Second Target</button>');
205+
secondPopover.target = secondTarget;
206+
secondPopover.trigger = [];
207+
secondPopover.renderer = (root) => {
208+
root.innerHTML = '<button>Second Popover content</button>';
209+
secondPopoverContent = root.firstElementChild;
210+
};
211+
await nextRender();
212+
});
213+
214+
describe('focus', () => {
215+
beforeEach(() => {
216+
popover.trigger = ['focus'];
217+
});
218+
219+
it('should close the popover when focus moves from target to non-nested popover', async () => {
220+
target.focus();
221+
await nextRender();
222+
223+
// open second popover
224+
secondPopover.opened = true;
225+
await nextRender();
226+
227+
secondPopoverContent.focus();
228+
229+
expect(popover.opened).to.be.false;
230+
});
231+
232+
it('should close when focus moves from the overlay to non-nested popover', async () => {
233+
target.focus();
234+
await nextRender();
235+
236+
popoverContent.focus();
237+
await nextRender();
238+
expect(popover.opened).to.be.true;
239+
240+
// open second popover
241+
secondPopover.opened = true;
242+
await nextRender();
243+
244+
secondPopoverContent.focus();
245+
await nextRender();
246+
247+
expect(popover.opened).to.be.false;
248+
});
249+
});
250+
251+
describe('hover', () => {
252+
beforeEach(() => {
253+
popover.trigger = ['hover'];
254+
});
255+
256+
it('should close the popover when mouse leaves target to non-nested popover', async () => {
257+
mouseenter(target);
258+
await nextRender();
259+
260+
// open second popover
261+
secondPopover.opened = true;
262+
await nextRender();
263+
264+
mouseleave(target, secondPopoverContent);
265+
await nextUpdate(popover);
266+
267+
expect(popover.opened).to.be.false;
268+
});
269+
270+
it('should close when mouse leaves the overlay to non-nested popover', async () => {
271+
mouseenter(target);
272+
await nextRender();
273+
274+
mouseenter(popoverContent);
275+
await nextRender();
276+
expect(popover.opened).to.be.true;
277+
278+
// open second popover
279+
secondPopover.opened = true;
280+
await nextRender();
281+
282+
mouseleave(popoverContent, secondPopoverContent);
283+
await nextUpdate(popover);
284+
285+
expect(popover.opened).to.be.false;
286+
});
287+
});
288+
});

0 commit comments

Comments
 (0)