Flow canvas: label and edge popovers follow pan and zoom, the editors' dock is a full-width bar on a phone

- InputInspector hangs off the label's element and EdgeInspector off a point in flow coordinates (ViewportPortal), both re-measured every frame while open and hidden once the anchor leaves the view. A label that leaves the canvas closes its popover.
- Dock: below md a bar across the width, flush with the bottom edge (bg-card, top border, safe-area inset, no shadow); from md the floating pill as before. The flow dock drops the Run caption and the dashboard editor clears the bar with its own padding, so the editing state fits one row from 360px.
- Tests: the search spec checks the popover rides along with a pan; the mobile spec checks both docks are a full-width bar flush with the bottom.
This commit is contained in:
2026-10-28 07:38:04 +01:00
parent 5a831ba758
commit 4c2df681d3
9 changed files with 109 additions and 35 deletions
+22 -8
View File
@@ -334,6 +334,25 @@ async function nodeAt(page: Page, id: string) {
return { x, y }
}
/**
* A phone's dock is a bar across the whole width, flush with the bottom edge,
* not a pill floating over the canvas. The id is any button in it.
*/
async function expectBar(page: Page, id: string) {
const bar = await page.getByTestId(id).locator("..").boundingBox()
const { width, height } = page.viewportSize() ?? { width: 0, height: 0 }
expect(bar, "the dock is not on screen").not.toBeNull()
expect(bar!.x, "the dock starts off the left edge").toBeCloseTo(0, 0)
expect(bar!.width, "the dock is not as wide as the phone").toBeCloseTo(
width,
0,
)
expect(bar!.y + bar!.height, "the dock floats above the bottom").toBeCloseTo(
height,
0,
)
}
test("the flow editor fits, and its dock is reachable", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
@@ -373,14 +392,7 @@ test("the flow editor fits, and its dock is reachable", async ({ page }) => {
).toBeLessThanOrEqual(width)
}
// It is centred on `left-1/2`, where a shrink-wrapped box gets only the half
// of the screen to the right of that, and wraps into a pill as tall as it is
// wide. Whatever the buttons need, it is never confined to that half.
const dock = await page.getByTestId("add-node").locator("..").boundingBox()
expect(
dock!.width,
"the dock is squeezed into half the screen",
).toBeGreaterThan((page.viewportSize()?.width ?? 0) / 2)
await expectBar(page, "add-node")
})
test("a dashboard stacks instead of shrinking", async ({ page }) => {
@@ -395,6 +407,8 @@ test("a dashboard stacks instead of shrinking", async ({ page }) => {
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(second!.y).toBeGreaterThanOrEqual(first!.y + first!.height - 1)
await expectBar(page, "toggle-edit")
})
/**
+31
View File
@@ -61,6 +61,37 @@ test("finds a flow input and reads it where its label sits", async ({
await expect(page.getByTestId("flow-panel")).toBeVisible()
})
test("the label popover rides along when the canvas is panned", async ({
page,
}) => {
const id = `input:${flowName}.findme_input`
await page.goto(`/flows/${flowName}?node=${encodeURIComponent(id)}`)
const popover = page.getByTestId("input-inspector")
const label = page.locator(`.react-flow__node[data-id="${id}"]`)
await expect(popover).toBeVisible()
// Its own entrance moves it too, which would read as part of the offset.
await expect
.poll(() => popover.evaluate((el) => el.getAnimations().length))
.toBe(0)
// Where the popover hangs off the label, which a pan must not change.
const offset = async () => {
const [from, to] = [await label.boundingBox(), await popover.boundingBox()]
return [Math.round(to!.x - from!.x), Math.round(to!.y - from!.y)]
}
const [before, at] = [await offset(), await label.boundingBox()]
const { width, height } = page.viewportSize() ?? { width: 0, height: 0 }
await page.mouse.move(width * 0.8, height * 0.2)
await page.mouse.down()
await page.mouse.move(width * 0.8 - 90, height * 0.2 + 50, { steps: 6 })
await page.mouse.up()
await expect.poll(async () => (await label.boundingBox())!.x).not.toBe(at!.x)
await expect(popover).toBeVisible()
await expect.poll(offset).toEqual(before)
})
test("opens on the keyboard from anywhere", async ({ page }) => {
await page.goto("/dashboards")
// The binding lives in the sidebar, so wait for it to be there to press at.