From d75024b22c999fb940825c338c2929caa40bd8c2 Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 22 May 2026 14:57:59 +0100 Subject: [PATCH] fix(webui): portal custom-select popup to body so cards' hover-transform can't break it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Location config dropdowns (Type, Path, etc.) live inside .task-item cards, whose :hover applies transform: translateY(-2px). A transformed element becomes the containing block for position:fixed descendants, so the popup — previously a child of the card — was positioned with viewport coords against the card instead of the viewport (wrong placement) and perturbed layout (content shifted left). Portal the popup to on open and detach on close, so position:fixed is always relative to the viewport regardless of any transformed/overflow ancestor. flip-up styling moves onto the popup element and the topbar's wider popup is carried via a class, since the popup no longer nests in the wrapper. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- containers/libreportal/frontend/css/forms.css | 4 ++-- .../libreportal/frontend/css/topbar.css | 2 +- .../frontend/js/system/custom-select.js | 21 +++++++++++++++---- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/containers/libreportal/frontend/css/forms.css b/containers/libreportal/frontend/css/forms.css index b5625e2..44638e1 100644 --- a/containers/libreportal/frontend/css/forms.css +++ b/containers/libreportal/frontend/css/forms.css @@ -884,7 +884,7 @@ select.form-control:focus { animation: customSelectIn 0.12s ease-out; } -.custom-select.flip-up .custom-select-popup { +.custom-select-popup.flip-up { transform-origin: bottom center; } @@ -893,7 +893,7 @@ select.form-control:focus { to { opacity: 1; transform: translateY(0); } } -.custom-select.flip-up .custom-select-popup { +.custom-select-popup.flip-up { animation-name: customSelectInUp; } diff --git a/containers/libreportal/frontend/css/topbar.css b/containers/libreportal/frontend/css/topbar.css index b7bbf02..8ca8659 100644 --- a/containers/libreportal/frontend/css/topbar.css +++ b/containers/libreportal/frontend/css/topbar.css @@ -252,6 +252,6 @@ line-height: 1.2; } -.topbar-controls .custom-select-popup { +.custom-select-popup-topbar { min-width: 140px; } diff --git a/containers/libreportal/frontend/js/system/custom-select.js b/containers/libreportal/frontend/js/system/custom-select.js index a28d246..917203b 100644 --- a/containers/libreportal/frontend/js/system/custom-select.js +++ b/containers/libreportal/frontend/js/system/custom-select.js @@ -56,10 +56,19 @@ this.popup.className = 'custom-select-popup'; this.popup.setAttribute('role', 'listbox'); this.popup.hidden = true; + // The popup is portaled to on open (below), escaping .topbar-controls, + // so carry the topbar's wider-popup styling via a class instead. + if (this.select.closest('.topbar-controls')) { + this.popup.classList.add('custom-select-popup-topbar'); + } - // Move the native select inside the wrapper so it stays in the form. + // Move the native select inside the wrapper so it stays in the form. The + // popup is deliberately NOT added here — it's portaled to on + // open() so no ancestor's transform (e.g. a card's :hover translateY, + // which becomes the containing block for position:fixed) can throw off + // its placement or clip it. See open()/close(). this.select.parentNode.insertBefore(this.wrapper, this.select); - this.wrapper.append(this.select, this.button, this.popup); + this.wrapper.append(this.select, this.button); this.select.classList.add('custom-select-native'); // Forward width-related layout from the native select to the wrapper. if (this.select.style.maxWidth) { @@ -105,6 +114,9 @@ document.querySelectorAll('.custom-select.is-open').forEach(w => { if (w !== this.wrapper) w.dispatchEvent(new CustomEvent('custom-select:close')); }); + // Portal into so position:fixed is relative to the viewport, + // immune to any transformed/overflow ancestor. + document.body.appendChild(this.popup); this.popup.hidden = false; this.button.setAttribute('aria-expanded', 'true'); this.wrapper.classList.add('is-open'); @@ -124,6 +136,7 @@ close() { if (this.popup.hidden) return; this.popup.hidden = true; + this.popup.remove(); // detach from so popups don't accumulate this.button.setAttribute('aria-expanded', 'false'); this.wrapper.classList.remove('is-open'); this.clearFocused(); @@ -141,7 +154,7 @@ const spaceBelow = window.innerHeight - rect.bottom; const popupMax = 280; const flipUp = spaceBelow < 200 && rect.top > popupMax; - this.wrapper.classList.toggle('flip-up', flipUp); + this.popup.classList.toggle('flip-up', flipUp); this.popup.style.left = `${rect.left}px`; this.popup.style.width = `${rect.width}px`; @@ -222,7 +235,7 @@ }; this._outsideClick = (e) => { - if (!this.wrapper.contains(e.target)) this.close(); + if (!this.wrapper.contains(e.target) && !this.popup.contains(e.target)) this.close(); }; this._reposition = () => this.positionPopup();