diff --git a/src/lib/components/common/Dropdown.svelte b/src/lib/components/common/Dropdown.svelte index 26b5a0a648..e1dc229062 100644 --- a/src/lib/components/common/Dropdown.svelte +++ b/src/lib/components/common/Dropdown.svelte @@ -25,6 +25,7 @@ let triggerEl; let contentEl; + let contentMaxHeight = ''; /** Svelte action: moves the node to document.body */ function portal(node) { @@ -68,6 +69,22 @@ contentEl.style.position = 'fixed'; contentEl.style.zIndex = '9999'; + // Find the trigger's nearest vertically-scrollable ancestor to use as boundary + let minTop = 8; + let scrollParent = triggerEl.parentElement; + while (scrollParent && scrollParent !== document.body) { + const style = getComputedStyle(scrollParent); + const oy = style.overflowY; + if ( + (oy === 'auto' || oy === 'scroll') && + scrollParent.scrollHeight > scrollParent.clientHeight + ) { + minTop = scrollParent.getBoundingClientRect().top; + break; + } + scrollParent = scrollParent.parentElement; + } + const contentHeight = contentEl.offsetHeight || 0; const spaceBelow = window.innerHeight - rect.bottom - sideOffset; const spaceAbove = rect.top - sideOffset; @@ -83,9 +100,14 @@ if (openAbove) { contentEl.style.bottom = `${window.innerHeight - rect.top + sideOffset}px`; contentEl.style.top = 'auto'; + const maxAbove = rect.top - minTop - sideOffset; + contentMaxHeight = `${maxAbove}px`; } else { - contentEl.style.top = `${rect.bottom + sideOffset}px`; + const dropdownTop = Math.max(minTop, rect.bottom + sideOffset); + contentEl.style.top = `${dropdownTop}px`; contentEl.style.bottom = 'auto'; + const availableHeight = window.innerHeight - dropdownTop - 8; + contentMaxHeight = `${availableHeight}px`; } if (align === 'end') { @@ -187,6 +209,8 @@ bind:this={contentEl} class={contentClass} role="menu" + style:max-height={contentMaxHeight} + style:overflow-y="auto" transition:flyAndScale on:click={(e) => e.stopPropagation()} on:pointerdown={(e) => e.stopPropagation()} diff --git a/src/lib/components/common/Select.svelte b/src/lib/components/common/Select.svelte index dfdfac0cce..0f2a6287b9 100644 --- a/src/lib/components/common/Select.svelte +++ b/src/lib/components/common/Select.svelte @@ -38,6 +38,7 @@ let triggerEl; let contentEl; + let contentMaxHeight = ''; $: selectedLabel = items.find((i) => i.value === value)?.label ?? placeholder; @@ -59,9 +60,29 @@ contentEl.style.position = 'fixed'; contentEl.style.zIndex = '9999'; - contentEl.style.top = `${rect.bottom + 4}px`; contentEl.style.minWidth = `${rect.width}px`; + // Find the trigger's nearest vertically-scrollable ancestor and use its + // top edge as the minimum dropdown position so the dropdown never overlaps + // fixed headers above the scroll area (e.g. workspace nav bar) + let minTop = 8; + let parent = triggerEl.parentElement; + while (parent && parent !== document.body) { + const style = getComputedStyle(parent); + const oy = style.overflowY; + if ((oy === 'auto' || oy === 'scroll') && parent.scrollHeight > parent.clientHeight) { + minTop = parent.getBoundingClientRect().top; + break; + } + parent = parent.parentElement; + } + const dropdownTop = Math.max(minTop, rect.bottom + 4); + contentEl.style.top = `${dropdownTop}px`; + + // Constrain dropdown height to available viewport space below the clamped top + const availableHeight = window.innerHeight - dropdownTop - 8; + contentMaxHeight = `${availableHeight}px`; + if (align === 'end') { contentEl.style.right = `${window.innerWidth - rect.right}px`; contentEl.style.left = 'auto'; @@ -123,7 +144,14 @@ {#if open} -