diff --git a/library/components/search/FilterDropdown.vue b/library/components/search/FilterDropdown.vue index 59d51bdfb..7ce69c32f 100644 --- a/library/components/search/FilterDropdown.vue +++ b/library/components/search/FilterDropdown.vue @@ -40,6 +40,68 @@ const searchText = ref('') const highlightedIndex = ref(-1) const rootRef = ref(null) const searchInputRef = ref(null) +const triggerRef = ref(null) + +/** + * ── ★ WHY THE MENU IS position:fixed AND NOT JUST A BIGGER z-index ── + * (card BUG-FILTER-DROPDOWN-HIDDEN-BEHIND-EVERYTHING-ZINDEX) + * + * MEASURED, not guessed: the menu's parent row (.sfh__filters-row in SearchFilterHeader) + * is the one-row horizontal scroller. It authors `overflow-x:auto; overflow-y:visible`, + * but CSS Overflow §3 COERCES a `visible` axis to `auto` when the other axis is not + * visible — so it computes to `overflow: auto auto` and CLIPS VERTICALLY. Live probe at + * 1440x900: the row's content box ended at y=95 while the open menu wanted y=62..343, so + * ~248px of the menu was never painted. A deep (shadow-piercing) hit test below y=95 hit + * .rd-stats and the facet rail — i.e. the content BEHIND the menu — because the menu did + * not exist there at all. + * + * Raising z-index CANNOT fix that, and a previous fix that raised the band to z-index:55 + * changed nothing: clipping happens during PAINTING, before stacking order is consulted. + * You cannot re-order pixels that were never generated. (z-index:55 was already winning + * the stacking fight it was sent to fight — which is why the bug survived that "fix".) + * + * position:fixed re-parents the menu to the VIEWPORT's containing block, which also takes + * it out of the scroller's clip chain. That is the whole fix; the accompanying high + * z-index only preserves paint order once the menu is free. No ancestor here sets + * transform/filter/perspective (the properties that WOULD capture a fixed descendant), + * verified live across the chain up to , so this is safe in the shadow-root/custom- + * element embedding all three consumers use. + * + * Because fixed coords are viewport-relative they go stale when anything scrolls or + * resizes, so they are recomputed on open and on scroll/resize while open. + */ +const menuStyle = ref({}) + +function positionMenu() { + const t = triggerRef.value + if (!t) return + const r = t.getBoundingClientRect() + const vw = window.innerWidth + const vh = window.innerHeight + const MENU_MAX = 320 + const GAP = 4 + const EDGE = 8 + + // Horizontal CLAMP. A fixed box is viewport-relative, so it does NOT follow the filters + // row's horizontal scroll the way an absolute child did — a trigger scrolled past the + // right edge (measured at 520px wide: three of six triggers sit at x=524..1024) would + // otherwise place the menu completely off-screen. Clamp into the viewport so the menu is + // always reachable, matching the trigger width when it fits. + const width = Math.round(Math.min(r.width, vw - EDGE * 2)) + const left = Math.round(Math.min(Math.max(r.left, EDGE), vw - width - EDGE)) + + // Flip above the trigger when there is not enough room below (keeps the list usable in + // a short band near the bottom of the viewport). + const below = vh - r.bottom + const flipUp = below < 180 && r.top > below + menuStyle.value = flipUp + ? { top: 'auto', bottom: `${Math.round(vh - r.top + GAP)}px`, left: `${left}px`, width: `${width}px`, maxHeight: `${Math.round(Math.min(MENU_MAX, r.top - GAP - EDGE))}px` } + : { top: `${Math.round(r.bottom + GAP)}px`, bottom: 'auto', left: `${left}px`, width: `${width}px`, maxHeight: `${Math.round(Math.min(MENU_MAX, below - GAP - EDGE))}px` } +} + +// `true` = capture phase, so this also sees scrolls inside the dashboard's inner scroll +// regions (they do not bubble to window). +function onReflow() { if (isOpen.value) positionMenu() } const filteredOptions = computed(() => { if (!searchText.value) return props.options @@ -60,8 +122,11 @@ function open() { isOpen.value = true searchText.value = '' highlightedIndex.value = -1 + positionMenu() document.addEventListener('click', onClickOutside) - nextTick(() => searchInputRef.value?.focus()) + window.addEventListener('scroll', onReflow, true) + window.addEventListener('resize', onReflow) + nextTick(() => { positionMenu(); searchInputRef.value?.focus() }) } function close() { @@ -69,6 +134,8 @@ function close() { searchText.value = '' highlightedIndex.value = -1 document.removeEventListener('click', onClickOutside) + window.removeEventListener('scroll', onReflow, true) + window.removeEventListener('resize', onReflow) } function onSearchInput(e) { @@ -119,12 +186,17 @@ function clear() { close() } -onBeforeUnmount(() => document.removeEventListener('click', onClickOutside)) +onBeforeUnmount(() => { + document.removeEventListener('click', onClickOutside) + window.removeEventListener('scroll', onReflow, true) + window.removeEventListener('resize', onReflow) +})