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 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 11:07:38 +03:00
parent 37c80f01af
commit 7b4b455c7d
2 changed files with 60 additions and 25 deletions

View File

@@ -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 {

View File

@@ -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 <select>. */
destroy() {
this._removeScrollListener();
this._trigger.remove();
this._popup.remove();
this._select.style.display = '';