From 7b4b455c7d1e8c3ebd12653bd34c6b480f0a23e2 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 10 Mar 2026 11:07:38 +0300 Subject: [PATCH] Fix IconSelect grid overflow and scroll jump - Set maxHeight dynamically based on available viewport space - Clamp popup horizontally to stay within viewport - Remove max-height CSS transition that caused scroll jumps - Auto-close popup on ancestor scroll to prevent stale positioning Co-Authored-By: Claude Opus 4.6 --- .../wled_controller/static/css/components.css | 10 +-- .../static/js/core/icon-select.js | 75 ++++++++++++++----- 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 4871b8c..980ba76 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -618,19 +618,15 @@ textarea:focus-visible { .icon-select-popup { position: fixed; z-index: 10000; - max-height: 0; overflow: hidden; opacity: 0; - transition: max-height 0.2s ease, opacity 0.15s ease, margin 0.2s ease; - margin-top: 0; + transition: opacity 0.15s ease; + pointer-events: none; } .icon-select-popup.open { - max-height: 600px; opacity: 1; - margin-top: 6px; -} -.icon-select-popup.open.settled { overflow-y: auto; + pointer-events: auto; } .icon-select-grid { diff --git a/server/src/wled_controller/static/js/core/icon-select.js b/server/src/wled_controller/static/js/core/icon-select.js index 0dd7e05..333fb0e 100644 --- a/server/src/wled_controller/static/js/core/icon-select.js +++ b/server/src/wled_controller/static/js/core/icon-select.js @@ -18,14 +18,12 @@ * Call sel.setValue(v) to change programmatically, sel.destroy() to remove. */ -import { t } from './i18n.js'; - const POPUP_CLASS = 'icon-select-popup'; /** Close every open icon-select popup. */ export function closeAllIconSelects() { document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => { - p.classList.remove('open', 'settled'); + p.classList.remove('open'); }); } @@ -78,7 +76,6 @@ export class IconSelect { this._popup = document.createElement('div'); this._popup.className = POPUP_CLASS; this._popup.addEventListener('click', (e) => e.stopPropagation()); - this._popup.addEventListener('transitionend', this._onTransitionEnd); this._popup.innerHTML = this._buildGrid(); document.body.appendChild(this._popup); @@ -86,7 +83,8 @@ export class IconSelect { this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { cell.addEventListener('click', () => { this.setValue(cell.dataset.value, true); - this._popup.classList.remove('open', 'settled'); + this._popup.classList.remove('open'); + this._removeScrollListener(); }); }); @@ -127,17 +125,32 @@ export class IconSelect { _positionPopup() { const rect = this._trigger.getBoundingClientRect(); - this._popup.style.left = rect.left + 'px'; - this._popup.style.width = Math.max(rect.width, 200) + 'px'; + const gap = 6; // visual gap between trigger and popup + const pad = 8; // min distance from viewport edge + const popupW = Math.max(rect.width, 200); + const spaceBelow = window.innerHeight - rect.bottom - gap - pad; + const spaceAbove = rect.top - gap - pad; - // Check if there's enough space below, otherwise open upward - const spaceBelow = window.innerHeight - rect.bottom; - const spaceAbove = rect.top; - if (spaceBelow < 250 && spaceAbove > spaceBelow) { + // Determine direction + const openUp = spaceBelow < 200 && spaceAbove > spaceBelow; + const available = openUp ? spaceAbove : spaceBelow; + + // Horizontal: clamp so popup doesn't overflow right edge + let left = rect.left; + if (left + popupW > window.innerWidth - pad) { + left = window.innerWidth - pad - popupW; + } + if (left < pad) left = pad; + + this._popup.style.left = left + 'px'; + this._popup.style.width = popupW + 'px'; + this._popup.style.maxHeight = available + 'px'; + + if (openUp) { this._popup.style.top = ''; - this._popup.style.bottom = (window.innerHeight - rect.top) + 'px'; + this._popup.style.bottom = (window.innerHeight - rect.top + gap) + 'px'; } else { - this._popup.style.top = rect.bottom + 'px'; + this._popup.style.top = (rect.bottom + gap) + 'px'; this._popup.style.bottom = ''; } } @@ -148,14 +161,39 @@ export class IconSelect { if (!wasOpen) { this._positionPopup(); this._popup.classList.add('open'); + this._addScrollListener(); } } - _onTransitionEnd = (e) => { - if (e.propertyName === 'max-height' && this._popup.classList.contains('open')) { - this._popup.classList.add('settled'); + /** Close popup when any scrollable ancestor scrolls (prevents stale position). */ + _addScrollListener() { + if (this._scrollHandler) return; + this._scrollHandler = () => { + this._popup.classList.remove('open'); + this._removeScrollListener(); + }; + // Listen on capture phase to catch scroll on any ancestor + let el = this._trigger.parentNode; + this._scrollTargets = []; + while (el && el !== document) { + if (el.scrollHeight > el.clientHeight || el.classList?.contains('modal-content')) { + el.addEventListener('scroll', this._scrollHandler, { passive: true }); + this._scrollTargets.push(el); + } + el = el.parentNode; } - }; + window.addEventListener('scroll', this._scrollHandler, { passive: true }); + this._scrollTargets.push(window); + } + + _removeScrollListener() { + if (!this._scrollHandler) return; + for (const el of this._scrollTargets) { + el.removeEventListener('scroll', this._scrollHandler); + } + this._scrollTargets = []; + this._scrollHandler = null; + } /** Change the value programmatically. */ setValue(value, fireChange = false) { @@ -175,7 +213,7 @@ export class IconSelect { this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { cell.addEventListener('click', () => { this.setValue(cell.dataset.value, true); - this._popup.classList.remove('open', 'settled'); + this._popup.classList.remove('open'); }); }); this._syncTrigger(); @@ -183,6 +221,7 @@ export class IconSelect { /** Remove the enhancement, restore native