refactor: replace inline dropdown hide logic with reusable outside-dismiss helpers

Add bindDropdownOutsideDismiss and pointer-guard utilities to downloadTargetMethods,
then use them in resolveDownloadMethods to remove duplicated hide-on-blur and
setTimeout-based hide patterns. This centralizes dropdown dismissal behavior and
handles pointer/mouse/touch events consistently across dropdown lists.
This commit is contained in:
Dariusz L
2026-06-23 05:02:59 +02:00
parent f94abe1489
commit 762363d7ef
2 changed files with 115 additions and 29 deletions
@@ -2067,13 +2067,8 @@ export const resolveDownloadMethods = {
});
};
const hideSourceList = () => {
setTimeout(() => {
sourceList.style.display = 'none';
}, 150);
};
this.enableWheelScrollChaining(sourceList);
this.bindDropdownOutsideDismiss?.(sourceList, [sourceSelect]);
sourceSelect.addEventListener('focus', () => renderSourceOptions());
sourceSelect.addEventListener('click', () => renderSourceOptions());
sourceSelect.addEventListener('keydown', (event) => {
@@ -2082,7 +2077,6 @@ export const resolveDownloadMethods = {
renderSourceOptions();
}
});
sourceSelect.addEventListener('blur', hideSourceList);
this.syncSearchSourceUi(missing, container);
}
@@ -2128,13 +2122,12 @@ export const resolveDownloadMethods = {
});
};
const hideBaseList = () => {
setTimeout(() => {
baseList.style.display = 'none';
}, 150);
};
this.enableWheelScrollChaining(baseList);
this.bindDropdownOutsideDismiss?.(baseList, [baseSelect], () => {
const value = this.getDropdownValue(baseSelect);
this.setDropdownValue(baseSelect, value, this.getSearchBaseModelLabel(value, missing));
baseList.style.display = 'none';
});
baseSelect.addEventListener('focus', () => renderBaseOptions(''));
baseSelect.addEventListener('click', () => renderBaseOptions(''));
baseSelect.addEventListener('input', () => {
@@ -2152,7 +2145,6 @@ export const resolveDownloadMethods = {
baseSelect.addEventListener('blur', () => {
const value = this.getDropdownValue(baseSelect);
this.setDropdownValue(baseSelect, value, this.getSearchBaseModelLabel(value, missing));
hideBaseList();
});
this.syncSearchSourceUi(missing, container);
}
+109 -15
View File
@@ -1482,8 +1482,107 @@ export const downloadTargetMethods = {
};
},
bindDropdownListPointerGuard(listEl) {
if (!listEl || listEl.dataset.mlPointerGuardBound === 'true') return;
listEl.dataset.mlPointerGuardBound = 'true';
let releaseTimer = null;
const clearPointerActive = () => {
if (releaseTimer) {
window.clearTimeout(releaseTimer);
releaseTimer = null;
}
listEl.dataset.mlPointerActive = 'false';
};
const markPointerActive = () => {
if (releaseTimer) {
window.clearTimeout(releaseTimer);
releaseTimer = null;
}
listEl.dataset.mlPointerActive = 'true';
};
const releasePointerActive = () => {
if (releaseTimer) {
window.clearTimeout(releaseTimer);
}
releaseTimer = window.setTimeout(() => {
clearPointerActive();
}, 250);
};
const isPointerInsideList = (event) => (
event?.target instanceof Node && listEl.contains(event.target)
) || this.isPointerEventInsideElementBounds(event, listEl, 24);
const clearPointerActiveIfOutside = (event) => {
if (isPointerInsideList(event)) {
markPointerActive();
return;
}
clearPointerActive();
};
window.addEventListener('pointerdown', clearPointerActiveIfOutside, true);
window.addEventListener('mousedown', clearPointerActiveIfOutside, true);
listEl.addEventListener('pointerdown', markPointerActive, true);
listEl.addEventListener('mousedown', markPointerActive, true);
window.addEventListener('pointerup', releasePointerActive, true);
window.addEventListener('mouseup', releasePointerActive, true);
window.addEventListener('blur', clearPointerActive, true);
},
isDropdownListPointerActive(listEl) {
return listEl?.dataset?.mlPointerActive === 'true';
},
isPointerEventInsideElementBounds(event, element, tolerance = 0) {
if (!event || !element || typeof event.clientX !== 'number' || typeof event.clientY !== 'number') {
return false;
}
const rect = element.getBoundingClientRect();
return event.clientX >= rect.left - tolerance
&& event.clientX <= rect.right + tolerance
&& event.clientY >= rect.top - tolerance
&& event.clientY <= rect.bottom + tolerance;
},
bindDropdownOutsideDismiss(listEl, anchorEls = [], onDismiss = null) {
if (!listEl || listEl.dataset.mlOutsideDismissBound === 'true') return;
listEl.dataset.mlOutsideDismissBound = 'true';
const anchors = Array.isArray(anchorEls)
? anchorEls.filter(Boolean)
: [anchorEls].filter(Boolean);
const dismiss = () => {
if (typeof onDismiss === 'function') {
onDismiss();
} else {
listEl.style.display = 'none';
}
};
const handleOutsidePointer = (event) => {
if (listEl.style.display === 'none') return;
const target = event?.target;
if (!(target instanceof Node)) return;
const isInsideList = listEl.contains(target)
|| this.isPointerEventInsideElementBounds(event, listEl, 24);
const isInsideAnchor = anchors.some(anchor => (
anchor.contains(target)
|| this.isPointerEventInsideElementBounds(event, anchor)
));
if (isInsideList || isInsideAnchor) {
return;
}
listEl.dataset.mlPointerActive = 'false';
dismiss();
};
window.addEventListener('pointerdown', handleOutsidePointer, true);
window.addEventListener('mousedown', handleOutsidePointer, true);
},
enableWheelScrollChaining(scrollEl) {
if (!scrollEl || scrollEl.dataset.mlWheelChainBound === 'true') return;
if (!scrollEl) return;
this.bindDropdownListPointerGuard(scrollEl);
if (scrollEl.dataset.mlWheelChainBound === 'true') return;
scrollEl.dataset.mlWheelChainBound = 'true';
scrollEl.addEventListener('wheel', (event) => {
@@ -1557,6 +1656,7 @@ export const downloadTargetMethods = {
cleanupFloatingPositioning();
listEl.style.display = 'none';
};
this.bindDropdownOutsideDismiss(listEl, [subfolderEl], hideFloatingSubfolderList);
const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max);
@@ -2001,16 +2101,6 @@ export const downloadTargetMethods = {
});
};
const hideList = (targetEl) => {
setTimeout(() => {
if (targetEl === listEl) {
hideFloatingSubfolderList();
} else {
targetEl.style.display = 'none';
}
}, 150);
};
if (categoryListEl && categoryEl.dataset.mlCategoryBound !== 'true') {
categoryEl.dataset.mlCategoryBound = 'true';
categoryEl.addEventListener('focus', () => populateCategoryOptions(''));
@@ -2042,13 +2132,19 @@ export const downloadTargetMethods = {
populateCategoryOptions(categoryEl.value);
}
});
categoryEl.addEventListener('blur', () => {
const normalizeCategoryInput = () => {
const category = this.getDropdownValue(categoryEl);
const knownCategories = this.getKnownDownloadCategorySet();
if (category && knownCategories.has(this.normalizeDownloadCategory(category))) {
this.setDropdownValue(categoryEl, category, this.getCategoryDisplayName(category));
}
hideList(categoryListEl);
};
this.bindDropdownOutsideDismiss(categoryListEl, [categoryEl], () => {
normalizeCategoryInput();
categoryListEl.style.display = 'none';
});
categoryEl.addEventListener('blur', () => {
normalizeCategoryInput();
});
}
@@ -2068,8 +2164,6 @@ export const downloadTargetMethods = {
populateSubfolderOptions(subfolderEl.value);
});
subfolderEl.addEventListener('blur', () => hideList(listEl));
if (suggestBtn && suggestBtn.dataset.mlSuggestBound !== 'true') {
suggestBtn.dataset.mlSuggestBound = 'true';
suggestBtn.addEventListener('click', async () => {