Files
LocalAI/core/http/react-ui/e2e/manage-action-menu-position.spec.js
LocalAI [bot] 3e96d811b7 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 <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-20 23:25:29 +02:00

51 lines
2.5 KiB
JavaScript

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)
})
})