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();