diff --git a/e2e/17-device-layout.spec.ts b/e2e/17-device-layout.spec.ts index 4f3d2b1..47de764 100644 --- a/e2e/17-device-layout.spec.ts +++ b/e2e/17-device-layout.spec.ts @@ -140,4 +140,30 @@ test.describe('ControlSurface device-layout board (AXIS-36)', () => { expect(ly).toBeTruthy(); expect(gy!.y).toBeLessThan(ly!.y); }); + + test('dropdown popover renders directly under its trigger, not the grid cell edge', async ({ page }) => { + await bootWithLayout(page); + await page.locator('[data-idx="0,0"].cell.block').click(); + await expect(page.locator('.boardwrap')).toBeVisible(); + + // Regression guard (AXIS dropdown-offset bug): the popover used to be positioned from the + // widget's grid cell bottom, which sits well below the trigger because `.card` centers its + // content — a select field is shorter than the knob-sized cell it lives in. The popover must + // track the actual rendered trigger element instead. + const trigger = page.locator('.selfield'); + await trigger.click(); + const menu = page.locator('.selmenu'); + await expect(menu).toBeVisible(); + + const t = await trigger.boundingBox(); + const m = await menu.boundingBox(); + expect(t).toBeTruthy(); + expect(m).toBeTruthy(); + + // Menu top should sit just under the trigger's bottom edge (a couple px, not a whole cell). + expect(m!.y - (t!.y + t!.height)).toBeGreaterThanOrEqual(0); + expect(m!.y - (t!.y + t!.height)).toBeLessThan(10); + // Left edge aligns with the trigger, not an unrelated grid column. + expect(Math.abs(m!.x - t!.x)).toBeLessThan(2); + }); }); diff --git a/src/lib/ControlSurface.svelte b/src/lib/ControlSurface.svelte index a5d2d2a..79032a1 100644 --- a/src/lib/ControlSurface.svelte +++ b/src/lib/ControlSurface.svelte @@ -170,6 +170,7 @@ let editingKey = $state(null); let editBuf = $state(''); let openSelect = $state(null); + let selMenuRect = $state<{ left: number; top: number; width: number } | null>(null); let renamingPage = $state(null); // value bubble: a single fixed-position tooltip (escapes the editor's scroll clipping + stays on top) let tip = $state<{ id: number; cx: number; cy: number; edit: boolean } | null>(null); @@ -1147,15 +1148,28 @@ if (result.success) editor.showToast(`Pinned ${paramIds.length} controls`, '#35c9d6'); } - // dropdown popover geometry + // dropdown popover geometry — measured from the actual trigger element (not grid math: the + // card centers its content, so a select field is usually shorter than its grid cell and sits + // away from the cell's edges). + function toggleSelectMenu(e: MouseEvent, id: string) { + if (openSelect === id) { + openSelect = null; + selMenuRect = null; + return; + } + const board = boardEl?.getBoundingClientRect(); + const trigger = (e.currentTarget as HTMLElement).getBoundingClientRect(); + if (!board) return; + selMenuRect = { left: trigger.left - board.left, top: trigger.bottom - board.top + 2, width: Math.max(trigger.width, 160) }; + openSelect = id; + } const selMenu = $derived.by(() => { - if (!openSelect) return null; - const w = find(openSelect); + if (!openSelect || !selMenuRect) return null; + const w = viewWidgets.find((w) => w.id === openSelect); const c = w && catByKey.get(w.key); const e = c && enm(c.id); if (!w || !c || !e) return null; - const step = cell + GAP; - return { id: w.id, e, left: w.x * step, top: (w.y + w.h) * step + 2, width: Math.max(w.w * cell + (w.w - 1) * GAP, 160) }; + return { id: w.id, e, ...selMenuRect }; }); // svg knob pointer transform @@ -1437,7 +1451,7 @@ {:else if c.kind === 'select'}
{c.label}
-
e.stopPropagation()} onclick={() => !editMode && (openSelect = openSelect === w.id ? null : w.id)}> +
e.stopPropagation()} onclick={(e) => !editMode && toggleSelectMenu(e, w.id)}> {enm(c.id)?.options.find((o) => o.value === enm(c.id)?.value)?.label ?? '–'}