From 3e96d811b7c507dd5e093f0625c00fff16d4a514 Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:25:29 +0200 Subject: [PATCH] fix(ui): keep row action menu anchored and stop scroll snap on /app/manage (#10419) Opening a model row's kebab (ActionMenu) on the Manage dashboard snapped the page scroll to the top and rendered the menu detached from its trigger, making it impossible to operate. Two compounding causes: - The menu auto-focus called el.focus() without preventScroll, so the browser scrolled the focused element into view, yanking the page to the top. - The position:fixed Popover was rendered inline inside the table row. The editorial UI overhaul added hover transforms to rows/cards, and a transformed ancestor re-anchors position:fixed to itself instead of the viewport, so the menu (positioned from the trigger's viewport rect) landed in the wrong place. Fix: portal the Popover to document.body so position:fixed always resolves against the viewport, position it before paint with useLayoutEffect (no {0,0} flash), and pass preventScroll:true to both focus calls. Adds an e2e regression test that reproduces the symptom (scroll jumped from 564 to 0 on the old code) and asserts the menu tracks its trigger. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- .../e2e/manage-action-menu-position.spec.js | 50 +++++++++++++++++++ .../react-ui/src/components/ActionMenu.jsx | 6 ++- core/http/react-ui/src/components/Popover.jsx | 21 ++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 core/http/react-ui/e2e/manage-action-menu-position.spec.js diff --git a/core/http/react-ui/e2e/manage-action-menu-position.spec.js b/core/http/react-ui/e2e/manage-action-menu-position.spec.js new file mode 100644 index 000000000..3f4301abe --- /dev/null +++ b/core/http/react-ui/e2e/manage-action-menu-position.spec.js @@ -0,0 +1,50 @@ +import { test, expect } from './coverage-fixtures.js' + +// Regression: opening a row's kebab (ActionMenu) on /app/manage used to snap +// the page scroll to the top and render the menu detached from its trigger, +// making it impossible to operate. Two causes: the menu auto-focus scrolled +// the page (no preventScroll), and the position:fixed popover was rendered +// inside a row whose hover `transform` re-anchored it. Fix portals the popover +// to document.body, positions it before paint, and focuses without scrolling. +test.describe('Manage Page - Action menu positioning', () => { + test('opening a row menu keeps scroll stable and places the menu by its trigger', async ({ page }) => { + // Small viewport so the page is scrollable and a scroll jump is observable. + await page.setViewportSize({ width: 1024, height: 500 }) + await page.goto('/app/manage') + await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 }) + + const trigger = page.locator('button.action-menu__trigger').first() + await expect(trigger).toBeVisible() + + // Bring the trigger into view ourselves first, so the only scroll we then + // measure is the one the menu would (wrongly) cause - not Playwright's own + // scroll-into-view before the click. + await trigger.scrollIntoViewIfNeeded() + const scrollBefore = await page.evaluate(() => window.scrollY) + await trigger.click() + + const menu = page.locator('[role="menu"]') + await expect(menu).toBeVisible() + + // Behavioural symptom 1: focusing the menu must not yank the page scroll. + const scrollAfter = await page.evaluate(() => window.scrollY) + expect(scrollAfter).toBe(scrollBefore) + + // Behavioural symptom 2: the menu must sit next to its trigger, not float + // at the top of the window where it can't be operated. + const triggerBox = await trigger.boundingBox() + const menuBox = await menu.boundingBox() + expect(triggerBox).not.toBeNull() + expect(menuBox).not.toBeNull() + // Menu top is within ~24px of the trigger's bottom (below) or above it + // (flipped) — in all cases it tracks the trigger, never floating at y≈0. + const tracksTrigger = + Math.abs(menuBox.y - (triggerBox.y + triggerBox.height)) < 24 || + Math.abs((menuBox.y + menuBox.height) - triggerBox.y) < 24 + expect(tracksTrigger).toBe(true) + + // Mechanism: the popover must be portaled to document.body so position:fixed + // resolves against the viewport, not a transformed ancestor row. + await expect(page.locator('body > .popover')).toHaveCount(1) + }) +}) diff --git a/core/http/react-ui/src/components/ActionMenu.jsx b/core/http/react-ui/src/components/ActionMenu.jsx index 5c58ecd78..55010102c 100644 --- a/core/http/react-ui/src/components/ActionMenu.jsx +++ b/core/http/react-ui/src/components/ActionMenu.jsx @@ -95,9 +95,11 @@ export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel, className="action-menu" onKeyDown={handleMenuKeyDown} // Capture focus when the menu opens so arrow keys work without the - // user clicking inside first. + // user clicking inside first. preventScroll: the popover is portaled + // and positioned by the trigger rect, so focusing it must not scroll + // the page (that yanked the view to the top before it was placed). tabIndex={-1} - ref={el => { if (el && open) el.focus() }} + ref={el => { if (el && open) el.focus({ preventScroll: true }) }} > {visible.map((item, i) => { if (item.divider) { diff --git a/core/http/react-ui/src/components/Popover.jsx b/core/http/react-ui/src/components/Popover.jsx index 96a9e217e..7d002d348 100644 --- a/core/http/react-ui/src/components/Popover.jsx +++ b/core/http/react-ui/src/components/Popover.jsx @@ -1,10 +1,17 @@ -import { useEffect, useRef, useState, useCallback } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react' +import { createPortal } from 'react-dom' // Minimal popover: positions itself below-right of the trigger's bounding box, // flips above when there isn't room below, closes on outside click or Escape, // returns focus to the trigger. Uses the existing .card surface so it picks // up theme/border/shadow automatically — no new theming work. // +// Rendered through a portal on document.body: the popover is position:fixed and +// positioned from the trigger's viewport rect, so it must escape any ancestor +// that establishes a containing block (a row/card with a hover `transform` +// would otherwise re-anchor `position:fixed` to itself, throwing the menu to +// the wrong spot and making it unusable). +// // Props: // anchor: ref to the trigger DOMElement (required) // open: boolean @@ -30,7 +37,9 @@ export default function Popover({ anchor, open, onClose, children, ariaLabel }) setPos({ top, left: Math.max(8, left), flipped }) }, [anchor]) - useEffect(() => { + // useLayoutEffect so we measure + place the popover before the browser + // paints — otherwise it flashes at its initial {0,0} for a frame. + useLayoutEffect(() => { if (!open) return reposition() window.addEventListener('resize', reposition) @@ -65,14 +74,15 @@ export default function Popover({ anchor, open, onClose, children, ariaLabel }) if (!open && anchor?.current) { // requestAnimationFrame so the close is painted before focus jumps; // otherwise screen readers announce the trigger mid-transition. - const raf = requestAnimationFrame(() => anchor.current?.focus?.()) + // preventScroll: focusing the trigger must not yank the page scroll. + const raf = requestAnimationFrame(() => anchor.current?.focus?.({ preventScroll: true })) return () => cancelAnimationFrame(raf) } }, [open, anchor]) if (!open) return null - return ( + return createPortal(
{children} -
+ , + document.body ) }