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:
@@ -160,6 +160,9 @@ Use `md` at 768px as the mobile boundary. Keep `useIsMobile()` and Tailwind's
|
|||||||
`overflow-x-auto` region. Use `min-w-0` on flex and grid children when
|
`overflow-x-auto` region. Use `min-w-0` on flex and grid children when
|
||||||
content could stretch the page. `frontend/tests/mobile.spec.ts` checks this.
|
content could stretch the page. `frontend/tests/mobile.spec.ts` checks this.
|
||||||
2. Cap floating controls with `max-w-[calc(100vw-2rem)]` and allow wrapping.
|
2. Cap floating controls with `max-w-[calc(100vw-2rem)]` and allow wrapping.
|
||||||
|
The editors' dock is the exception below `md`: a bar across the full width,
|
||||||
|
flush with the bottom edge (`bg-card`, top border, no shadow, safe-area
|
||||||
|
inset), whose brand button drops its caption so the usual row fits at 44px.
|
||||||
3. Use 44px touch targets below `md`, 32px above. Use `icon-touch`, `sm-touch`
|
3. Use 44px touch targets below `md`, 32px above. Use `icon-touch`, `sm-touch`
|
||||||
or matching responsive utilities.
|
or matching responsive utilities.
|
||||||
4. Turn floating panels into full-screen sheets on phones with the shared
|
4. Turn floating panels into full-screen sheets on phones with the shared
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ import { slideUp, transitions } from "@/lib/motion"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The action bar floating at the bottom of an editor canvas, flow or dashboard.
|
* The action bar at the bottom of an editor canvas, flow or dashboard.
|
||||||
*
|
*
|
||||||
* Capped and wrapping: the canvas shell clips, so an uncapped row would put the
|
* A phone has no room to float: there it is a bar across the full width, flush
|
||||||
* buttons at its ends out of reach on a phone rather than merely look wrong.
|
* with the bottom edge, with the buttons packed to fit one row. From `md` it is
|
||||||
* See DESIGN-GUIDELINES.md → Responsive.
|
* a pill floating over the canvas, capped and wrapping, since the canvas shell
|
||||||
|
* clips and an uncapped row would put the buttons at its ends out of reach.
|
||||||
|
* The caller places it, with the same split. See DESIGN-GUIDELINES.md →
|
||||||
|
* Responsive.
|
||||||
*/
|
*/
|
||||||
export function Dock({
|
export function Dock({
|
||||||
className,
|
className,
|
||||||
@@ -43,7 +46,7 @@ export function Dock({
|
|||||||
exit="exit"
|
exit="exit"
|
||||||
transition={transitions.emphasized}
|
transition={transitions.emphasized}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto flex w-max max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]",
|
"pointer-events-auto flex w-full flex-wrap items-center justify-around border-t border-border bg-card pt-1 pb-[max(0.25rem,env(safe-area-inset-bottom))] md:w-max md:max-w-[calc(100vw-2rem)] md:justify-center md:gap-1 md:rounded-full md:border md:bg-card/80 md:px-1.5 md:py-1 md:shadow-e2 md:backdrop-blur-md",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -801,10 +801,11 @@ export function DashboardEditor({
|
|||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: see above. */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: see above. */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
|
// Clear of the dock: a bar on a phone, a pill from `md`.
|
||||||
|
"absolute inset-0 overflow-hidden px-4 pb-[calc(3.5rem+env(safe-area-inset-bottom))] pt-20 transition-[padding] duration-200 md:pb-24",
|
||||||
panelOpen && "md:pr-[27rem]",
|
panelOpen && "md:pr-[27rem]",
|
||||||
// Clear of the row of faces above the dock.
|
// Clear of the row of faces above the dock.
|
||||||
edit && round && "pb-40",
|
edit && round && "pb-40 md:pb-40",
|
||||||
)}
|
)}
|
||||||
data-testid="dashboard-canvas"
|
data-testid="dashboard-canvas"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -835,7 +836,7 @@ export function DashboardEditor({
|
|||||||
arranging never mounts the lock, so the controls are live. */}
|
arranging never mounts the lock, so the controls are live. */}
|
||||||
{edit ? null : <LockNotice dashboard={draft} />}
|
{edit ? null : <LockNotice dashboard={draft} />}
|
||||||
|
|
||||||
<Dock className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
|
<Dock className="absolute inset-x-0 bottom-0 z-10 md:inset-x-auto md:bottom-4 md:left-1/2 md:-translate-x-1/2">
|
||||||
{edit ? (
|
{edit ? (
|
||||||
<>
|
<>
|
||||||
{/* A canvas can be shrunk to less than what is arranged on it,
|
{/* A canvas can be shrunk to less than what is arranged on it,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation } from "@tanstack/react-query"
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { ViewportPortal } from "@xyflow/react"
|
||||||
import { ArrowRight, RotateCcw, Trash2 } from "lucide-react"
|
import { ArrowRight, RotateCcw, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { FlowsService } from "@/client"
|
import { FlowsService } from "@/client"
|
||||||
@@ -37,6 +38,7 @@ export type InspectedEdge = {
|
|||||||
to: string
|
to: string
|
||||||
/** The producing node's id, which is what replaying the message goes through. */
|
/** The producing node's id, which is what replaying the message goes through. */
|
||||||
sourceId: string
|
sourceId: string
|
||||||
|
/** Where it was clicked, in flow coordinates, so it stays on the edge. */
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
}
|
}
|
||||||
@@ -75,12 +77,18 @@ export function EdgeInspector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open onOpenChange={(open) => !open && onClose()}>
|
<Popover open onOpenChange={(open) => !open && onClose()}>
|
||||||
<PopoverAnchor
|
{/* In the viewport, so the pan and zoom carry it. Re-measured every
|
||||||
style={{ position: "fixed", left: edge.x, top: edge.y }}
|
frame while open, and out of sight once the point is. */}
|
||||||
className="size-0"
|
<ViewportPortal>
|
||||||
/>
|
<PopoverAnchor
|
||||||
|
style={{ position: "absolute", left: edge.x, top: edge.y }}
|
||||||
|
className="pointer-events-none size-0"
|
||||||
|
/>
|
||||||
|
</ViewportPortal>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
align="center"
|
align="center"
|
||||||
|
updatePositionStrategy="always"
|
||||||
|
hideWhenDetached
|
||||||
className="w-72 p-3"
|
className="w-72 p-3"
|
||||||
data-testid="edge-inspector"
|
data-testid="edge-inspector"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ export const FIT_VIEW_PANEL = {
|
|||||||
* saved, the flow's own settings, and putting it live or throwing it away —
|
* saved, the flow's own settings, and putting it live or throwing it away —
|
||||||
* so the top of the canvas is left to say which flow this is.
|
* so the top of the canvas is left to say which flow this is.
|
||||||
*
|
*
|
||||||
* It wraps rather than overflows, and drops the fit control on a phone; see
|
* On a phone it is a bar across the bottom edge, which drops the fit control
|
||||||
* DESIGN-GUIDELINES.md → Responsive.
|
* and the caption on Run to leave the row room; otherwise it floats and wraps
|
||||||
|
* rather than overflows. See DESIGN-GUIDELINES.md → Responsive.
|
||||||
*/
|
*/
|
||||||
export function FlowDock({
|
export function FlowDock({
|
||||||
flow,
|
flow,
|
||||||
@@ -121,7 +122,7 @@ export function FlowDock({
|
|||||||
// gives the two panels a row to share, instead of one landing on the other.
|
// gives the two panels a row to share, instead of one landing on the other.
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute bottom-4 left-1/2 z-10 flex w-max max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-col items-center",
|
"pointer-events-none absolute inset-x-0 bottom-0 z-10 flex flex-col items-center md:inset-x-auto md:bottom-4 md:left-1/2 md:w-max md:max-w-[calc(100vw-2rem)] md:-translate-x-1/2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -178,6 +179,8 @@ export function FlowDock({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm-touch"
|
size="sm-touch"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Narrower on a phone, where the bar is the width of the screen.
|
||||||
|
"max-md:has-[>svg]:px-1.5",
|
||||||
faults.length > 0
|
faults.length > 0
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
: issuesOpen
|
: issuesOpen
|
||||||
@@ -246,8 +249,11 @@ export function FlowDock({
|
|||||||
<Button
|
<Button
|
||||||
variant="brand"
|
variant="brand"
|
||||||
size="sm-touch"
|
size="sm-touch"
|
||||||
|
// A phone's bar has the width for the glyph, not the caption.
|
||||||
|
className="w-11 md:w-auto"
|
||||||
onClick={onRun}
|
onClick={onRun}
|
||||||
disabled={running || !enabled}
|
disabled={running || !enabled}
|
||||||
|
aria-label="Run"
|
||||||
data-testid="run-flow"
|
data-testid="run-flow"
|
||||||
>
|
>
|
||||||
{running ? (
|
{running ? (
|
||||||
@@ -255,7 +261,7 @@ export function FlowDock({
|
|||||||
) : (
|
) : (
|
||||||
<Play className="size-4" />
|
<Play className="size-4" />
|
||||||
)}
|
)}
|
||||||
Run
|
<span className="hidden md:inline">Run</span>
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ function FlowEditorInner({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { showErrorToast, showSuccessToast } = useCustomToast()
|
const { showErrorToast, showSuccessToast } = useCustomToast()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||||
const updateNodeInternals = useUpdateNodeInternals()
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
||||||
@@ -565,8 +565,7 @@ function FlowEditorInner({
|
|||||||
)
|
)
|
||||||
const label = document.querySelector(`.react-flow__node[data-id="${id}"]`)
|
const label = document.querySelector(`.react-flow__node[data-id="${id}"]`)
|
||||||
if (!declared || !label) return openEndpoint(id)
|
if (!declared || !label) return openEndpoint(id)
|
||||||
const { left, top, width, height } = label.getBoundingClientRect()
|
setInspectedInput({ declared, label })
|
||||||
setInspectedInput({ declared, rect: { left, top, width, height } })
|
|
||||||
},
|
},
|
||||||
[flowName, openEndpoint],
|
[flowName, openEndpoint],
|
||||||
)
|
)
|
||||||
@@ -748,6 +747,15 @@ function FlowEditorInner({
|
|||||||
[renderedNodes, externalNodes, positions],
|
[renderedNodes, externalNodes, positions],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A label that has left the canvas, because its input is gone or a dashboard
|
||||||
|
// now accounts for it, leaves the popover nothing to hang off.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the nodes are what take a label away.
|
||||||
|
useEffect(() => {
|
||||||
|
if (inspectedInput && !inspectedInput.label.isConnected) {
|
||||||
|
setInspectedInput(null)
|
||||||
|
}
|
||||||
|
}, [shownNodes, inspectedInput])
|
||||||
|
|
||||||
// Editing ports adds and removes handles, and flipping direction moves them
|
// Editing ports adds and removes handles, and flipping direction moves them
|
||||||
// to the other side. React Flow measures those once, so it has to be told,
|
// to the other side. React Flow measures those once, so it has to be told,
|
||||||
// or an edge to a brand-new handle never gets drawn.
|
// or an edge to a brand-new handle never gets drawn.
|
||||||
@@ -1399,8 +1407,7 @@ function FlowEditorInner({
|
|||||||
from: label(edge.source),
|
from: label(edge.source),
|
||||||
to: label(edge.target),
|
to: label(edge.target),
|
||||||
sourceId: edge.source,
|
sourceId: edge.source,
|
||||||
x: event.clientX,
|
...screenToFlowPosition({ x: event.clientX, y: event.clientY }),
|
||||||
y: event.clientY,
|
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import { ValuePreview } from "./ValuePreview"
|
|||||||
export type InspectedInput = {
|
export type InspectedInput = {
|
||||||
/** The declared input, as the flow document holds it. */
|
/** The declared input, as the flow document holds it. */
|
||||||
declared: FlowInput_Input
|
declared: FlowInput_Input
|
||||||
/** The label clicked, in viewport pixels, which the popover hangs off. */
|
/** The label's element: the popover hangs off it wherever the canvas goes. */
|
||||||
rect: { left: number; top: number; width: number; height: number }
|
label: Element
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,13 +43,14 @@ export function InputInspector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open onOpenChange={(open) => !open && onClose()}>
|
<Popover open onOpenChange={(open) => !open && onClose()}>
|
||||||
<PopoverAnchor
|
<PopoverAnchor virtualRef={{ current: input.label }} />
|
||||||
style={{ position: "fixed", ...input.rect }}
|
{/* Re-measured every frame while open, so it rides along with a pan or a
|
||||||
className="pointer-events-none"
|
zoom, and out of sight once the label is. */}
|
||||||
/>
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
align="center"
|
align="center"
|
||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
|
updatePositionStrategy="always"
|
||||||
|
hideWhenDetached
|
||||||
className="w-72 p-3"
|
className="w-72 p-3"
|
||||||
data-testid="input-inspector"
|
data-testid="input-inspector"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -334,6 +334,25 @@ async function nodeAt(page: Page, id: string) {
|
|||||||
return { x, y }
|
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 }) => {
|
test("the flow editor fits, and its dock is reachable", async ({ page }) => {
|
||||||
await page.goto(`/flows/${flowName}`)
|
await page.goto(`/flows/${flowName}`)
|
||||||
await page.waitForSelector(".react-flow__node")
|
await page.waitForSelector(".react-flow__node")
|
||||||
@@ -373,14 +392,7 @@ test("the flow editor fits, and its dock is reachable", async ({ page }) => {
|
|||||||
).toBeLessThanOrEqual(width)
|
).toBeLessThanOrEqual(width)
|
||||||
}
|
}
|
||||||
|
|
||||||
// It is centred on `left-1/2`, where a shrink-wrapped box gets only the half
|
await expectBar(page, "add-node")
|
||||||
// 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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a dashboard stacks instead of shrinking", async ({ page }) => {
|
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(first).not.toBeNull()
|
||||||
expect(second).not.toBeNull()
|
expect(second).not.toBeNull()
|
||||||
expect(second!.y).toBeGreaterThanOrEqual(first!.y + first!.height - 1)
|
expect(second!.y).toBeGreaterThanOrEqual(first!.y + first!.height - 1)
|
||||||
|
|
||||||
|
await expectBar(page, "toggle-edit")
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -61,6 +61,37 @@ test("finds a flow input and reads it where its label sits", async ({
|
|||||||
await expect(page.getByTestId("flow-panel")).toBeVisible()
|
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 }) => {
|
test("opens on the keyboard from anywhere", async ({ page }) => {
|
||||||
await page.goto("/dashboards")
|
await page.goto("/dashboards")
|
||||||
// The binding lives in the sidebar, so wait for it to be there to press at.
|
// The binding lives in the sidebar, so wait for it to be there to press at.
|
||||||
|
|||||||
Reference in New Issue
Block a user