Files
app/frontend/src/components/Common/Dock.tsx
T
stroblme a4393381ab 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.

Claude-Session: https://claude.ai/code/session_01SSi5AvZ7pFmySK8zy3NPMh
2026-10-28 07:38:04 +01:00

200 lines
5.2 KiB
TypeScript

import { Check, WifiOff, X } from "lucide-react"
import { motion } from "motion/react"
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { FluksioLoader } from "@/components/ui/fluksio-loader"
import { Separator } from "@/components/ui/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
/**
* The action bar at the bottom of an editor canvas, flow or dashboard.
*
* A phone has no room to float: there it is a bar across the full width, flush
* with the bottom edge, with the buttons packed to fit one row. From `md` it is
* 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({
className,
children,
}: {
className?: string
children: ReactNode
}) {
return (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className={cn(
"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,
)}
>
{children}
</motion.div>
)
}
/** Groups the dock's buttons; a phone wraps instead. */
export function DockSeparator() {
return (
<Separator orientation="vertical" className="mx-0.5 !h-5 hidden md:block" />
)
}
/**
* Saved state and publish are one control: the glyph never moves, it simply
* stops being something you can press once there is nothing left to put live.
* A button that appears and disappears moved everything beside it just as the
* work was finished.
*/
export function PublishButton({
label,
testId,
hasDraft,
saving,
publishing,
offline = false,
draftHint,
onPublish,
}: {
label: string
testId: string
hasDraft: boolean
saving: boolean
publishing: boolean
/** The engine is out of reach, so there is nothing to publish to. */
offline?: boolean
/** What the tooltip says while there is a saved draft waiting. */
draftHint: string
onPublish: () => void
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={onPublish}
disabled={!hasDraft || publishing || saving || offline}
aria-label={label}
data-testid={testId}
>
{offline ? (
<WifiOff className="size-3.5" />
) : saving || publishing ? (
<FluksioLoader className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{offline
? "Reconnecting to the engine"
: publishing
? "Publishing"
: saving
? "Saving"
: hasDraft
? draftHint
: "All changes saved"}
</TooltipContent>
</Tooltip>
)
}
/** Sits next to publish, and only while there is something to throw away. */
export function DiscardButton({
testId,
onClick,
}: {
testId: string
onClick: () => void
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={onClick}
aria-label="Discard the unpublished changes"
data-testid={testId}
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>Discard the unpublished changes</TooltipContent>
</Tooltip>
)
}
/** The confirmation behind `DiscardButton`; `children` says what goes back. */
export function DiscardDraftDialog({
open,
onOpenChange,
pending,
testId,
onConfirm,
children,
}: {
open: boolean
onOpenChange: (open: boolean) => void
pending: boolean
testId: string
onConfirm: () => void
children: ReactNode
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>{children}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Keep editing
</Button>
<Button
variant="destructive"
disabled={pending}
onClick={() => {
onOpenChange(false)
onConfirm()
}}
data-testid={testId}
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}