From 1cfc0beeb6c36331a496baf4cb6aa8741b0bcd4d Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 23 Jun 2026 06:22:57 +0200 Subject: [PATCH] refactor: replace inline dropdown display toggling with reusable floating positioning helpers Switch download target dropdowns from absolute-positioned elements clipped by scroll containers to body-ported fixed-position lists managed by downloadTargetMethods. Adds showDropdownList, hideDropdownList, portalDropdownList, positionFloatingDropdownList, and related helpers so the UI can keep dropdowns visible and correctly placed near their anchors. --- web/css/resolver-main.css | 13 +- .../actions/resolve_download_methods.js | 14 +- .../search/download_target_methods.js | 171 +++++++++++++++++- 3 files changed, 184 insertions(+), 14 deletions(-) diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 2b6f044..7659bb2 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -5642,12 +5642,16 @@ } } .mr-download-target-list { + box-sizing: border-box; position: absolute; top: calc(100% + 4px); left: 0; - right: 0; + right: auto; + width: 100%; + min-width: 100%; max-height: 220px; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; display: none; z-index: var(--mr-z-notification); background: linear-gradient(180deg, var(--mr-panel-bg-strong) 0%, var(--mr-card-bg) 100%); @@ -5669,11 +5673,16 @@ z-index: var(--mr-z-context-menu); } .mr-download-target-option { + box-sizing: border-box; + max-width: 100%; padding: 8px 10px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.04); color: var(--mr-text); font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .mr-download-target-option:last-child { border-bottom: none; diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index cda33d1..db175bf 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -2054,7 +2054,7 @@ export const resolveDownloadMethods = { sourceList.innerHTML = options .map(option => `
${this.escapeHtml(option.label)}
`) .join(''); - sourceList.style.display = 'block'; + this.showDropdownList?.(sourceList, sourceSelect); sourceList.querySelectorAll('.mr-download-target-option').forEach(optionEl => { optionEl.addEventListener('mousedown', (event) => { event.preventDefault(); @@ -2062,7 +2062,7 @@ export const resolveDownloadMethods = { const label = decodeURIComponent(optionEl.dataset.label || optionEl.dataset.value || ''); this.setDropdownValue(sourceSelect, value, label); this.setSearchSource(missing, value, container); - sourceList.style.display = 'none'; + this.hideDropdownList(sourceList); }); }); }; @@ -2109,7 +2109,11 @@ export const resolveDownloadMethods = { return `
${this.escapeHtml(label)}
`; }) .join(''); - baseList.style.display = options.length ? 'block' : 'none'; + if (options.length) { + this.showDropdownList?.(baseList, baseSelect); + } else { + this.hideDropdownList(baseList); + } baseList.querySelectorAll('.mr-download-target-option').forEach(optionEl => { optionEl.addEventListener('mousedown', (event) => { event.preventDefault(); @@ -2117,7 +2121,7 @@ export const resolveDownloadMethods = { const label = decodeURIComponent(optionEl.dataset.label || optionEl.dataset.value || ''); this.setDropdownValue(baseSelect, value, label); this.setSearchBaseModel(missing, value, container); - baseList.style.display = 'none'; + this.hideDropdownList(baseList); }); }); }; @@ -2126,7 +2130,7 @@ export const resolveDownloadMethods = { this.bindDropdownOutsideDismiss?.(baseList, [baseSelect], () => { const value = this.getDropdownValue(baseSelect); this.setDropdownValue(baseSelect, value, this.getSearchBaseModelLabel(value, missing)); - baseList.style.display = 'none'; + this.hideDropdownList(baseList); }); baseSelect.addEventListener('focus', () => renderBaseOptions('')); baseSelect.addEventListener('click', () => renderBaseOptions('')); diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index a551f00..c656e64 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -1555,7 +1555,7 @@ export const downloadTargetMethods = { if (typeof onDismiss === 'function') { onDismiss(); } else { - listEl.style.display = 'none'; + this.hideDropdownList(listEl); } }; const handleOutsidePointer = (event) => { @@ -1579,6 +1579,163 @@ export const downloadTargetMethods = { window.addEventListener('mousedown', handleOutsidePointer, true); }, + portalDropdownList(listEl) { + if (!listEl) return; + if (listEl.id) { + document.querySelectorAll('.mr-download-target-list[data-ml-floating-portal="true"]').forEach(existing => { + if (existing !== listEl && existing.id === listEl.id) { + existing.remove(); + } + }); + } + if (listEl.dataset.mlFloatingPortal !== 'true') { + listEl.dataset.mlFloatingPortal = 'true'; + document.body.appendChild(listEl); + } + listEl.classList.add('mr-download-target-floating'); + }, + + cleanupFloatingDropdownPositioning(listEl) { + if (typeof listEl?._mlFloatingPositionCleanup === 'function') { + listEl._mlFloatingPositionCleanup(); + listEl._mlFloatingPositionCleanup = null; + } + }, + + hideDropdownList(listEl) { + if (!listEl) return; + this.cleanupFloatingDropdownPositioning(listEl); + listEl.style.display = 'none'; + }, + + positionFloatingDropdownList(listEl, anchorEl) { + if (!listEl || !anchorEl || listEl.style.display === 'none') return; + if (listEl.classList.contains('mr-download-folder-browser')) return; + + const rect = anchorEl.getBoundingClientRect(); + const viewportPadding = 12; + const viewportHeight = Math.max(240, window.innerHeight || document.documentElement.clientHeight || 0); + if (rect.bottom < viewportPadding || rect.top > viewportHeight - viewportPadding) { + this.hideDropdownList(listEl); + return; + } + + const gap = 4; + const spaceBelow = viewportHeight - rect.bottom - viewportPadding - gap; + const spaceAbove = rect.top - viewportPadding - gap; + const openAbove = spaceBelow < 160 && spaceAbove > spaceBelow; + const availableHeight = Math.max(80, openAbove ? spaceAbove : spaceBelow); + + listEl.style.position = 'fixed'; + listEl.style.top = '0px'; + listEl.style.maxHeight = `${availableHeight}px`; + this.fitDropdownListWidth(listEl, anchorEl); + + const popupHeight = Math.min(listEl.offsetHeight || availableHeight, availableHeight); + const rawTop = openAbove + ? rect.top - popupHeight - gap + : rect.bottom + gap; + const maxTop = Math.max(viewportPadding, viewportHeight - viewportPadding - popupHeight); + const top = Math.min(Math.max(rawTop, viewportPadding), maxTop); + listEl.style.top = `${Math.round(top)}px`; + }, + + bindFloatingDropdownPositioning(listEl, anchorEl) { + if (!listEl || !anchorEl || listEl.classList.contains('mr-download-folder-browser')) return; + this.cleanupFloatingDropdownPositioning(listEl); + + const updatePosition = (event) => { + if (event?.type === 'scroll' && event.target instanceof Node && listEl.contains(event.target)) { + return; + } + this.positionFloatingDropdownList(listEl, anchorEl); + }; + window.addEventListener('resize', updatePosition, true); + window.addEventListener('scroll', updatePosition, true); + listEl._mlFloatingPositionCleanup = () => { + window.removeEventListener('resize', updatePosition, true); + window.removeEventListener('scroll', updatePosition, true); + }; + }, + + showDropdownList(listEl, anchorEl = null) { + if (!listEl) return; + const anchor = anchorEl + || listEl.previousElementSibling + || listEl.parentElement?.querySelector?.('.mr-download-target-input'); + if (!anchor) { + listEl.style.display = 'block'; + return; + } + + this.portalDropdownList(listEl); + listEl.style.display = 'block'; + this.positionFloatingDropdownList(listEl, anchor); + this.bindFloatingDropdownPositioning(listEl, anchor); + requestAnimationFrame(() => this.positionFloatingDropdownList(listEl, anchor)); + }, + + fitDropdownListWidth(listEl, anchorEl = null) { + if (!listEl || listEl.classList.contains('mr-download-folder-browser')) return; + + const anchor = anchorEl + || listEl.previousElementSibling + || listEl.parentElement?.querySelector?.('.mr-download-target-input'); + if (!anchor) return; + + const anchorRect = anchor.getBoundingClientRect(); + const inputWidth = Math.ceil(anchorRect.width || 0); + if (!inputWidth) return; + + const viewportPadding = 12; + const viewportWidth = Math.max(320, window.innerWidth || document.documentElement.clientWidth || 0); + const maxByInput = inputWidth * 2; + const maxByViewport = Math.max(inputWidth, viewportWidth - viewportPadding * 2); + const absoluteMaxWidth = Math.max(inputWidth, Math.min(maxByInput, maxByViewport)); + + listEl.style.minWidth = `${inputWidth}px`; + listEl.style.width = `${inputWidth}px`; + listEl.style.maxWidth = `${absoluteMaxWidth}px`; + + const listStyle = window.getComputedStyle(listEl); + const listChromeWidth = [ + listStyle.borderLeftWidth, + listStyle.borderRightWidth, + listStyle.paddingLeft, + listStyle.paddingRight + ].reduce((width, value) => width + (Number.parseFloat(value) || 0), 0); + const optionWidths = Array.from(listEl.querySelectorAll('.mr-download-target-option')).map(option => ( + Math.max(option.scrollWidth, option.getBoundingClientRect().width || 0) + )); + const contentWidth = Math.ceil(Math.max(inputWidth, ...optionWidths) + listChromeWidth + 2); + let targetWidth = Math.max(inputWidth, Math.min(contentWidth, absoluteMaxWidth)); + const rightSpace = Math.max(inputWidth, viewportWidth - viewportPadding - anchorRect.left); + const leftSpace = Math.max(inputWidth, anchorRect.right - viewportPadding); + + if (targetWidth > rightSpace && leftSpace > rightSpace) { + targetWidth = Math.min(targetWidth, leftSpace); + if (listEl.classList.contains('mr-download-target-floating')) { + listEl.style.left = `${Math.round(Math.max(viewportPadding, anchorRect.right - targetWidth))}px`; + listEl.style.right = 'auto'; + } else { + listEl.style.left = 'auto'; + listEl.style.right = '0'; + } + } else { + targetWidth = Math.min(targetWidth, rightSpace); + if (listEl.classList.contains('mr-download-target-floating')) { + listEl.style.left = `${Math.round(Math.max(viewportPadding, anchorRect.left))}px`; + listEl.style.right = 'auto'; + } else { + listEl.style.left = '0'; + listEl.style.right = 'auto'; + } + } + + listEl.style.width = `${Math.round(targetWidth)}px`; + listEl.style.maxWidth = `${Math.round(targetWidth)}px`; + }, + enableWheelScrollChaining(scrollEl) { if (!scrollEl) return; this.bindDropdownListPointerGuard(scrollEl); @@ -1746,7 +1903,7 @@ export const downloadTargetMethods = { requestAnimationFrame(positionFloatingSubfolderList); }; - const renderOptions = (targetEl, values, onSelect) => { + const renderOptions = (targetEl, values, onSelect, anchorEl = null) => { const options = values.map(value => ( typeof value === 'object' ? value @@ -1754,7 +1911,7 @@ export const downloadTargetMethods = { )); if (!options.length) { targetEl.innerHTML = ''; - targetEl.style.display = 'none'; + this.hideDropdownList(targetEl); return; } @@ -1768,7 +1925,7 @@ export const downloadTargetMethods = { }) .join(''); - targetEl.style.display = 'block'; + this.showDropdownList(targetEl, anchorEl); targetEl.querySelectorAll('.mr-download-target-option').forEach(option => { option.addEventListener('mousedown', (event) => { @@ -1777,7 +1934,7 @@ export const downloadTargetMethods = { const label = decodeURIComponent(option.dataset.label || option.dataset.value || ''); const baseDirectory = decodeURIComponent(option.dataset.baseDirectory || ''); onSelect(value, label, baseDirectory); - targetEl.style.display = 'none'; + this.hideDropdownList(targetEl); }); }); }; @@ -2080,7 +2237,7 @@ export const downloadTargetMethods = { hideFloatingSubfolderList(); this.syncDownloadTargetFolderContext(categoryEl, subfolderEl); this.applySuggestedDownloadSubfolder(missing, categoryEl, subfolderEl); - }); + }, categoryEl); }; const populateSubfolderOptions = async (filterText = '') => { @@ -2141,7 +2298,7 @@ export const downloadTargetMethods = { }; this.bindDropdownOutsideDismiss(categoryListEl, [categoryEl], () => { normalizeCategoryInput(); - categoryListEl.style.display = 'none'; + this.hideDropdownList(categoryListEl); }); categoryEl.addEventListener('blur', () => { normalizeCategoryInput();