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 { .icon-select-popup {
position: fixed; position: fixed;
z-index: 10000; z-index: 10000;
max-height: 0;
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;
transition: max-height 0.2s ease, opacity 0.15s ease, margin 0.2s ease; transition: opacity 0.15s ease;
margin-top: 0; pointer-events: none;
} }
.icon-select-popup.open { .icon-select-popup.open {
max-height: 600px;
opacity: 1; opacity: 1;
margin-top: 6px;
}
.icon-select-popup.open.settled {
overflow-y: auto; overflow-y: auto;
pointer-events: auto;
} }
.icon-select-grid { .icon-select-grid {

View File

@@ -18,14 +18,12 @@
* Call sel.setValue(v) to change programmatically, sel.destroy() to remove. * Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
*/ */
import { t } from './i18n.js';
const POPUP_CLASS = 'icon-select-popup'; const POPUP_CLASS = 'icon-select-popup';
/** Close every open icon-select popup. */ /** Close every open icon-select popup. */
export function closeAllIconSelects() { export function closeAllIconSelects() {
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => { 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 = document.createElement('div');
this._popup.className = POPUP_CLASS; this._popup.className = POPUP_CLASS;
this._popup.addEventListener('click', (e) => e.stopPropagation()); this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.addEventListener('transitionend', this._onTransitionEnd);
this._popup.innerHTML = this._buildGrid(); this._popup.innerHTML = this._buildGrid();
document.body.appendChild(this._popup); document.body.appendChild(this._popup);
@@ -86,7 +83,8 @@ export class IconSelect {
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => { cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true); 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() { _positionPopup() {
const rect = this._trigger.getBoundingClientRect(); const rect = this._trigger.getBoundingClientRect();
this._popup.style.left = rect.left + 'px'; const gap = 6; // visual gap between trigger and popup
this._popup.style.width = Math.max(rect.width, 200) + 'px'; 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 // Determine direction
const spaceBelow = window.innerHeight - rect.bottom; const openUp = spaceBelow < 200 && spaceAbove > spaceBelow;
const spaceAbove = rect.top; const available = openUp ? spaceAbove : spaceBelow;
if (spaceBelow < 250 && 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.top = '';
this._popup.style.bottom = (window.innerHeight - rect.top) + 'px'; this._popup.style.bottom = (window.innerHeight - rect.top + gap) + 'px';
} else { } else {
this._popup.style.top = rect.bottom + 'px'; this._popup.style.top = (rect.bottom + gap) + 'px';
this._popup.style.bottom = ''; this._popup.style.bottom = '';
} }
} }
@@ -148,14 +161,39 @@ export class IconSelect {
if (!wasOpen) { if (!wasOpen) {
this._positionPopup(); this._positionPopup();
this._popup.classList.add('open'); this._popup.classList.add('open');
this._addScrollListener();
} }
} }
_onTransitionEnd = (e) => { /** Close popup when any scrollable ancestor scrolls (prevents stale position). */
if (e.propertyName === 'max-height' && this._popup.classList.contains('open')) { _addScrollListener() {
this._popup.classList.add('settled'); 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. */ /** Change the value programmatically. */
setValue(value, fireChange = false) { setValue(value, fireChange = false) {
@@ -175,7 +213,7 @@ export class IconSelect {
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => { cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true); this.setValue(cell.dataset.value, true);
this._popup.classList.remove('open', 'settled'); this._popup.classList.remove('open');
}); });
}); });
this._syncTrigger(); this._syncTrigger();
@@ -183,6 +221,7 @@ export class IconSelect {
/** Remove the enhancement, restore native <select>. */ /** Remove the enhancement, restore native <select>. */
destroy() { destroy() {
this._removeScrollListener();
this._trigger.remove(); this._trigger.remove();
this._popup.remove(); this._popup.remove();
this._select.style.display = ''; this._select.style.display = '';