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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -2054,7 +2054,7 @@ export const resolveDownloadMethods = {
|
||||
sourceList.innerHTML = options
|
||||
.map(option => `<div class="mr-download-target-option" data-value="${encodeURIComponent(option.value)}" data-label="${encodeURIComponent(option.label)}">${this.escapeHtml(option.label)}</div>`)
|
||||
.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 `<div class="mr-download-target-option" data-value="${encodeURIComponent(option.value)}" data-label="${encodeURIComponent(label)}">${this.escapeHtml(label)}</div>`;
|
||||
})
|
||||
.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(''));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user