Skip to content

Commit 35a0b57

Browse files
authored
fix(ui5-popover): close the popover once it is out of the viewport (#13200)
1 parent 6193cec commit 35a0b57

3 files changed

Lines changed: 157 additions & 0 deletions

File tree

packages/main/cypress/specs/Popover.cy.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Label from "../../src/Label.js";
77
import List from "../../src/List.js";
88
import ListItem from "../../src/ListItemStandard.js";
99
import Input from "../../src/Input.js";
10+
import Dialog from "../../src/Dialog.js";
1011

1112
describe("Rendering", () => {
1213
it("tests arrow positioning", () => {
@@ -1810,3 +1811,88 @@ describe("Responsive paddings", () => {
18101811
cy.get("[ui5-popover]").should("have.attr", "media-range", "M");
18111812
});
18121813
});
1814+
1815+
describe("Opener visibility in scrollable containers", () => {
1816+
it("should close popover when opener scrolls out of view in scrollable container", () => {
1817+
cy.mount(
1818+
<div id="scrollContainer" style={{ height: "200px", overflowY: "auto" }}>
1819+
<div style={{ height: "500px" }}>
1820+
<Button id="opener" style={{ marginTop: "100px" }}>Open</Button>
1821+
<Popover opener="opener" open={true}>
1822+
<div>Popover Content</div>
1823+
</Popover>
1824+
</div>
1825+
</div>
1826+
);
1827+
1828+
cy.get<Popover>("[ui5-popover]").ui5PopoverOpened();
1829+
1830+
cy.get("#scrollContainer").scrollTo(0, 200);
1831+
1832+
cy.get("[ui5-popover]").should("have.prop", "open", false);
1833+
});
1834+
1835+
it("should close popover when opener scrolls out in Dialog scenario", () => {
1836+
cy.mount(
1837+
<Dialog open={true}>
1838+
<div slot="header" style={{ height: "200px" }}>LargeHeader</div>
1839+
<div id="dialogScrollContainer" style={{ height: "200px", overflowY: "auto" }}>
1840+
<div style={{ height: "1000px" }}>
1841+
<Button id="dialogOpener" style={{ marginTop: "100px" }}>Opener</Button>
1842+
<Popover opener="dialogOpener" open={true}>
1843+
<div>Popover in Dialog</div>
1844+
</Popover>
1845+
</div>
1846+
</div>
1847+
</Dialog>
1848+
);
1849+
1850+
cy.get<Popover>("[ui5-popover]").ui5PopoverOpened();
1851+
1852+
cy.get("#dialogScrollContainer").scrollTo(0, 500);
1853+
1854+
cy.get("[ui5-popover]").should("have.prop", "open", false);
1855+
});
1856+
1857+
it("should work with nested scrollable containers", () => {
1858+
cy.mount(
1859+
<div style={{ height: "300px", overflowY: "auto" }}>
1860+
<div style={{ height: "500px" }}>
1861+
<div id="innerScroll" style={{ height: "150px", overflowY: "auto", marginTop: "50px" }}>
1862+
<div style={{ height: "800px" }}>
1863+
<Button id="nestedOpener" style={{ marginTop: "80px" }}>Nested Opener</Button>
1864+
<Popover opener="nestedOpener" open={true}>
1865+
<div>Nested Popover</div>
1866+
</Popover>
1867+
</div>
1868+
</div>
1869+
</div>
1870+
</div>
1871+
);
1872+
1873+
cy.get<Popover>("[ui5-popover]").ui5PopoverOpened();
1874+
1875+
cy.get("#innerScroll").scrollTo(0, 300);
1876+
1877+
cy.get("[ui5-popover]").should("have.prop", "open", false);
1878+
});
1879+
1880+
it("should handle horizontal scrolling", () => {
1881+
cy.mount(
1882+
<div id="horizontalScroll" style={{ width: "200px", height: "200px", overflowX: "auto" }}>
1883+
<div style={{ width: "1000px", height: "100px" }}>
1884+
<Button id="hOpener" style={{ marginLeft: "100px" }}>Horizontal Opener</Button>
1885+
<Popover opener="hOpener" open={true}>
1886+
<div>Horizontal Popover</div>
1887+
</Popover>
1888+
</div>
1889+
</div>
1890+
);
1891+
1892+
cy.get<Popover>("[ui5-popover]").ui5PopoverOpened();
1893+
1894+
cy.get("#horizontalScroll").scrollTo(500, 0);
1895+
1896+
cy.get("[ui5-popover]").should("have.prop", "open", false);
1897+
});
1898+
});

packages/main/src/Popover.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ class Popover extends Popup {
227227
_oldPlacement?: CalculatedPlacement;
228228
_width?: string;
229229
_height?: string;
230+
_openerIntersectionObserver?: IntersectionObserver | null;
230231

231232
_popoverResize: PopoverResize;
232233

@@ -290,11 +291,14 @@ class Popover extends Popup {
290291
this._initialHeight = this.style.height;
291292

292293
this._openerRect = opener.getBoundingClientRect();
294+
this._observeOpenerVisibility();
293295

294296
await super.openPopup();
295297
}
296298

297299
closePopup(escPressed = false, preventRegistryUpdate = false, preventFocusRestore = false) : void {
300+
this._unobserveOpenerVisibility();
301+
298302
Object.assign(this.style, {
299303
width: this._initialWidth,
300304
height: this._initialHeight,
@@ -363,6 +367,10 @@ class Popover extends Popup {
363367

364368
let rootNode = this.getRootNode();
365369

370+
if (!rootNode) {
371+
return null;
372+
}
373+
366374
if (rootNode === this) {
367375
rootNode = document;
368376
}
@@ -550,6 +558,46 @@ class Popover extends Popup {
550558
return top + (Number.parseInt(this.style.top || "0") - actualTop);
551559
}
552560

561+
/**
562+
* Callback invoked when the opener element's intersection status changes.
563+
* Closes the popover when the opener is no longer visible.
564+
* @private
565+
*/
566+
_onOpenerIntersection(entries: Array<IntersectionObserverEntry>): void {
567+
if (this.open && !entries[0]?.isIntersecting) {
568+
this.closePopup();
569+
}
570+
}
571+
572+
/**
573+
* Starts observing the opener element's visibility in the viewport.
574+
* @private
575+
*/
576+
_observeOpenerVisibility(): void {
577+
this._unobserveOpenerVisibility();
578+
579+
const opener = this.getOpenerHTMLElement(this.opener);
580+
581+
if (!opener) {
582+
return;
583+
}
584+
585+
this._openerIntersectionObserver = new IntersectionObserver(this._onOpenerIntersection.bind(this));
586+
587+
this._openerIntersectionObserver.observe(opener);
588+
}
589+
590+
/**
591+
* Stops observing the opener element and cleans up the IntersectionObserver instance.
592+
* @private
593+
*/
594+
_unobserveOpenerVisibility(): void {
595+
if (this._openerIntersectionObserver) {
596+
this._openerIntersectionObserver.disconnect();
597+
this._openerIntersectionObserver = null;
598+
}
599+
}
600+
553601
getPopoverSize(calcScrollHeight: boolean = false): PopoverSize {
554602
const rect = this.getBoundingClientRect();
555603
const width = rect.width;

packages/main/test/pages/Popover.html

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,21 @@
228228
<ui5-button id="focusMe">Close</ui5-button>
229229
</div>
230230
</ui5-popover>
231+
232+
<br>
233+
<br>
234+
<ui5-button id="closePopoverAfterScroll">Open popover and scroll</ui5-button>
235+
<ui5-dialog id="scrollableDialog">
236+
<div slot="header" style="height: 200px">LargeHeader</div>
237+
<div style="height: 200px; overflow: auto">
238+
<div style="height: 1000px">
239+
<ui5-input value-state="Negative">
240+
<span slot="valueStateMessage">Message</span>
241+
</ui5-input>
242+
</div>
243+
</div>
244+
<ui5-button slot="footer" id="closeAfterScrollBtn" design="Emphasized">Close</ui5-button>
245+
</ui5-dialog>
231246

232247
<br>
233248
<br>
@@ -747,6 +762,14 @@ <h3>Popover in ShadowRoot, Opener set as ID in window.document</h3>
747762
document.getElementById("big-popover").open = true;
748763
});
749764

765+
document.getElementById("closePopoverAfterScroll").addEventListener("click", function (event) {
766+
scrollableDialog.open = true;
767+
});
768+
769+
document.getElementById("closeAfterScrollBtn").addEventListener("click", function (event) {
770+
scrollableDialog.open = false;
771+
});
772+
750773
document.getElementById("acc-role-popover-button").addEventListener("click", function (event) {
751774
document.getElementById("acc-role-popover").opener = event.target;
752775
document.getElementById("acc-role-popover").open = true;

0 commit comments

Comments
 (0)