Skip to content

Commit a37b106

Browse files
committed
Merge branch 'fix-inoperable-croninput' into 'enterprise'
fix(ui): portals were closing when a nested portal opened See merge request dkinternal/testgen/dataops-testgen!447
2 parents d3d3be1 + f5747aa commit a37b106

2 files changed

Lines changed: 71 additions & 26 deletions

File tree

testgen/ui/components/frontend/js/components/portal.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const Portal = (/** @type Options */ options, ...args) => {
2323
const { target, targetRelative, align = 'left', position = 'bottom' } = getValue(options);
2424
const id = `${target}-portal`;
2525

26-
window.testgen.portals[id] = { domId: id, targetId: target, opened: options.opened };
26+
window.testgen.portals[id] = { domId: id, targetId: target, opened: options.opened, close: () => { options.opened.val = false; } };
2727

2828
return () => {
2929
if (!getValue(options.opened)) {

testgen/ui/static/js/components/portal.js

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,71 +18,116 @@
1818
import van from '../van.min.js';
1919
import { getValue } from '../utils.js';
2020

21+
const { div } = van.tags;
22+
2123
const STREAMLIT_DIALOG_ZINDEX = 1000060;
2224
const STREAMLIT_DIALOG_CLASS = 'stDialog';
2325

2426
const Portal = (/** @type Options */ options, ...args) => {
2527
const { target, align = 'left', position = 'bottom' } = getValue(options);
2628
const id = `${target}-portal`;
27-
let portalEl = null;
2829
let outsideClickHandler = null;
2930

3031
const close = () => { options.opened.val = false; };
3132

3233
window.testgen.portals[id] = { domId: id, targetId: target, opened: options.opened, close };
3334

35+
// Side-effect derive: manages close loop and outside-click handler.
36+
// Kept free of van.add / DOM creation to avoid corrupting VanJS dependency tracking.
3437
van.derive(() => {
3538
const isOpen = getValue(options.opened);
3639

3740
if (!isOpen) {
38-
portalEl?.remove();
39-
portalEl = null;
4041
if (outsideClickHandler) {
4142
document.removeEventListener('click', outsideClickHandler, true);
4243
outsideClickHandler = null;
4344
}
4445
return;
4546
}
4647

47-
// Close other open portals before opening this one
48+
const anchor = document.getElementById(target);
49+
if (!anchor) return;
50+
51+
// Close other open portals — skip parent portals that contain our anchor.
52+
const toClose = [];
4853
for (const p of Object.values(window.testgen.portals)) {
49-
if (p.domId !== id && getValue(p.opened)) {
50-
p.close();
54+
if (p.domId !== id && p.opened?.rawVal) {
55+
const otherEl = document.getElementById(p.domId);
56+
if (otherEl?.contains(anchor)) continue;
57+
toClose.push(p);
5158
}
5259
}
60+
if (toClose.length) {
61+
queueMicrotask(() => toClose.forEach(p => { p.opened.val = false; }));
62+
}
63+
64+
if (!outsideClickHandler) {
65+
outsideClickHandler = (event) => {
66+
const anchor = document.getElementById(target);
67+
const portalEl = document.getElementById(id);
68+
if (portalEl?.contains(event.target)) return;
69+
if (anchor?.contains(event.target)) return;
70+
if (isClickInsideChildPortal(event.target, id, portalEl)) return;
71+
close();
72+
};
73+
document.addEventListener('click', outsideClickHandler, true);
74+
}
75+
});
76+
77+
// DOM rendering: a VanJS binding on document.body.
78+
// VanJS manages the element lifecycle natively — no manual createElement/remove.
79+
van.add(document.body, () => {
80+
if (!getValue(options.opened)) {
81+
return '';
82+
}
5383

5484
const anchor = document.getElementById(target);
55-
if (!anchor) return;
85+
if (!anchor) return '';
5686

5787
const fixed = hasFixedAncestor(anchor);
5888
const fromDialog = hasStreamlitDialogAncestor(anchor);
59-
const zIndex = fromDialog ? (STREAMLIT_DIALOG_ZINDEX + 1) : 1001;
89+
const parentPortalEl = getParentPortalElement(anchor, id);
90+
const zIndex = parentPortalEl
91+
? (parseInt(parentPortalEl.style.zIndex) || 1001) + 1
92+
: fromDialog ? (STREAMLIT_DIALOG_ZINDEX + 1) : 1001;
6093
const coords = position === 'bottom'
6194
? calculateBottomPosition(anchor, align, fixed)
6295
: calculateTopPosition(anchor, align, fixed);
6396

64-
if (!portalEl) {
65-
portalEl = document.createElement('div');
66-
document.body.appendChild(portalEl);
67-
van.add(portalEl, ...args);
68-
69-
outsideClickHandler = (event) => {
70-
const anchor = document.getElementById(target);
71-
if (!portalEl?.contains(event.target) && !anchor?.contains(event.target)) {
72-
close();
73-
}
74-
};
75-
document.addEventListener('click', outsideClickHandler, true);
76-
}
77-
78-
portalEl.id = id;
79-
portalEl.className = getValue(options.class) ?? '';
80-
portalEl.style.cssText = `position: ${fixed ? 'fixed' : 'absolute'}; z-index: ${zIndex}; ${coords} ${getValue(options.style) ?? ''}`;
97+
return div(
98+
{
99+
id,
100+
class: getValue(options.class) ?? '',
101+
style: `position: ${fixed ? 'fixed' : 'absolute'}; z-index: ${zIndex}; ${coords} ${getValue(options.style) ?? ''}`,
102+
},
103+
...args,
104+
);
81105
});
82106

83107
return '';
84108
};
85109

110+
function getParentPortalElement(anchor, selfId) {
111+
for (const p of Object.values(window.testgen.portals)) {
112+
if (p.domId === selfId) continue;
113+
const el = document.getElementById(p.domId);
114+
if (el?.contains(anchor)) return el;
115+
}
116+
return null;
117+
}
118+
119+
function isClickInsideChildPortal(target, selfId, selfPortalEl) {
120+
for (const p of Object.values(window.testgen.portals)) {
121+
if (p.domId === selfId) continue;
122+
const childEl = document.getElementById(p.domId);
123+
if (childEl?.contains(target)) {
124+
const childAnchor = document.getElementById(p.targetId);
125+
if (selfPortalEl?.contains(childAnchor)) return true;
126+
}
127+
}
128+
return false;
129+
}
130+
86131
function hasFixedAncestor(el) {
87132
let node = el.parentElement;
88133
while (node && node !== document.body) {

0 commit comments

Comments
 (0)