From 407a1ac7153b771f135c5a1c8501dcb0a6b50800 Mon Sep 17 00:00:00 2001 From: Alex Ngo Date: Wed, 19 Aug 2026 16:19:28 -0700 Subject: [PATCH] fix(viewer): stop clipping the eval results table and its metric dropdowns. --- viewer/src/lib/PrimerDropdown.svelte | 64 +++++++++++++++++++ .../src/routes/suite/[suite_id]/+page.svelte | 52 +++++++++++++-- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/viewer/src/lib/PrimerDropdown.svelte b/viewer/src/lib/PrimerDropdown.svelte index b7deb232b..f80f36cfe 100644 --- a/viewer/src/lib/PrimerDropdown.svelte +++ b/viewer/src/lib/PrimerDropdown.svelte @@ -36,6 +36,47 @@ let highlightedIndex = $state(-1); let triggerButton: HTMLButtonElement; let menuList: HTMLUListElement; + // The menu is rendered with `position: fixed` and anchored to the trigger's + // viewport rect so it can escape any ancestor with a scroll/clip context + // (e.g. the `overflow-x-auto` wrapper around the evaluation-results table). + // An absolutely-positioned menu is clipped by such an ancestor and also + // inflates its scrollWidth, which spawns a stray horizontal scrollbar. + let menuStyle = $state(''); + + const VIEWPORT_MARGIN = 8; + const TRIGGER_GAP = 4; + const MIN_MENU_WIDTH = 192; // matches the 12rem min-width in CSS + + function positionMenu() { + if (!triggerButton || !menuList) return; + const anchor = triggerButton.getBoundingClientRect(); + const menu = menuList.getBoundingClientRect(); + + // Prefer left-aligned to the trigger; flip to right-aligned when that would + // overflow the viewport, then clamp so the menu is never off-screen. + let left = anchor.left; + if (left + menu.width > window.innerWidth - VIEWPORT_MARGIN) { + left = anchor.right - menu.width; + } + left = Math.max( + VIEWPORT_MARGIN, + Math.min(left, window.innerWidth - menu.width - VIEWPORT_MARGIN) + ); + + // Prefer opening downward; flip above the trigger only when there is room. + let top = anchor.bottom + TRIGGER_GAP; + const overflowsBottom = top + menu.height > window.innerHeight - VIEWPORT_MARGIN; + const fitsAbove = anchor.top - TRIGGER_GAP - menu.height >= VIEWPORT_MARGIN; + if (overflowsBottom && fitsAbove) { + top = anchor.top - TRIGGER_GAP - menu.height; + } + top = Math.max(VIEWPORT_MARGIN, top); + + menuStyle = + `position: fixed; top: ${Math.round(top)}px; left: ${Math.round(left)}px; ` + + `min-width: ${Math.round(Math.max(MIN_MENU_WIDTH, anchor.width))}px;`; + } + function handleSelect(value: string) { selected = value; @@ -107,6 +148,28 @@ } }); + $effect(() => { + if (!open) { + menuStyle = ''; + return; + } + // First pass positions from the pre-layout rect; the rAF pass corrects once + // the menu has been laid out at its final size. + positionMenu(); + const frame = requestAnimationFrame(positionMenu); + + const reposition = () => positionMenu(); + // Capture phase so scrolling of any ancestor container is observed too. + window.addEventListener('scroll', reposition, true); + window.addEventListener('resize', reposition); + + return () => { + cancelAnimationFrame(frame); + window.removeEventListener('scroll', reposition, true); + window.removeEventListener('resize', reposition); + }; + }); + $effect(() => { if (open && highlightedIndex >= 0 && menuList) { const items = menuList.querySelectorAll('[role="option"]'); @@ -146,6 +209,7 @@ bind:this={menuList} class="ActionList ActionMenu-list" role="listbox" + style={menuStyle} onkeydown={handleKeyDown} onblur={handleMenuBlur} tabindex={-1} diff --git a/viewer/src/routes/suite/[suite_id]/+page.svelte b/viewer/src/routes/suite/[suite_id]/+page.svelte index 126ee63a7..f40d6b2b5 100644 --- a/viewer/src/routes/suite/[suite_id]/+page.svelte +++ b/viewer/src/routes/suite/[suite_id]/+page.svelte @@ -819,7 +819,7 @@

No evaluation results yet.

{:else} -
+
@@ -833,7 +833,7 @@ {#each selectedMetricCols as colDim, colIdx (colIdx)} -
Run date Run status + {/if} -
- {run.run_id} - {runTarget(run)} +
+ {run.run_id} + {runTarget(run)}
@@ -1168,3 +1168,45 @@ {/if} {/if} + + \ No newline at end of file