Files
app/frontend/src/components/Flow/SidePanel.tsx
T
stroblmeandClaude Fable 5 fa12ffd323 Copy nodes, bind the keys, and keep publish within reach
The floating bars used to make way for a side panel, which hid Publish
exactly when a node had just been edited. They keep their lane now and
the panel is inset out of it; the enlarged code editor stays a floating
surface between the two bars rather than covering them.

Shortcuts are one small registry (`lib/shortcuts.ts`): undo, redo, ⌘K,
copy, paste, and ⌘S — which publishes the flow, or applies the code when
the editor has focus. Copying carries a node's own source through
localStorage, so a paste works in another flow too.

A flow is now named where a node is, at the top of its panel and only
there, and both take effect on Confirm. Flow-level edits go through the
undo stack with everything else, clicking the canvas puts the flow panel
away, a failing node opens the logs filtered to its own traceback, and
the panel carries its test id on a phone as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:43:41 +02:00

202 lines
5.4 KiB
TypeScript

import { X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import type { ReactNode } from "react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useIsMobile } from "@/hooks/useMobile"
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
import { cn } from "@/lib/utils"
/** Same grammar as the shared `slideUp`, on the axis this panel travels. */
const panelSlide = {
hidden: { opacity: 0, x: 16 },
visible: {
opacity: 1,
x: 0,
transition: { duration: duration.base, ease: easeEmphasized },
},
exit: {
opacity: 0,
x: 16,
transition: { duration: duration.fast, ease: easeStandard },
},
}
export const PANEL_SECTION =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
/**
* What the thing in this panel is called, and the one place to rename it.
*
* Renaming is an explicit act: the field keeps what is typed until it is
* confirmed, so a half-typed name never reaches the canvas.
*/
export function PanelTitle({
value,
placeholder,
label,
onConfirm,
}: {
value: string
placeholder?: string
/** Names the field for screen readers. */
label: string
onConfirm: (next: string) => void
}) {
const [draft, setDraft] = useState(value)
// Another thing to name is another field.
useEffect(() => setDraft(value), [value])
const next = draft.trim()
const changed = next !== value
return (
<>
<Input
value={draft}
placeholder={placeholder}
aria-label={label}
autoComplete="off"
className="h-8 min-w-0 flex-1 text-sm font-medium"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && changed) onConfirm(next)
}}
/>
{changed ? (
<Button
size="sm"
className="h-8 shrink-0"
onClick={() => onConfirm(next)}
data-testid="confirm-rename"
>
Confirm
</Button>
) : null}
</>
)
}
/**
* The editor's settings panel: floating over the canvas so the graph stays
* visible and running behind it, a full-screen sheet where there is no room
* for that.
*
* Node settings and flow settings share it, so the two read as one surface.
*/
export function SidePanel({
open,
label,
testId,
bodyKey,
expanded = false,
header,
footer,
children,
onClose,
}: {
open: boolean
/** Names the panel for screen readers. */
label: string
testId: string
/** Remounts the contents when the thing being edited changes. */
bodyKey: string
/** Take the width a code editor needs, still floating over the canvas. */
expanded?: boolean
header: ReactNode
footer?: ReactNode
children: ReactNode
onClose: () => void
}) {
const isMobile = useIsMobile()
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, onClose])
const contents = (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3">
{header}
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={onClose}
aria-label="Close"
>
<X />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{children}
</div>
{footer ? (
<div className="shrink-0 border-t border-border px-4 py-3">
{footer}
</div>
) : null}
</>
)
if (isMobile) {
return (
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
<SheetContent
side="right"
data-testid={testId}
// The panel header carries its own close button, and opening should
// not drop the caret into the first field.
className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<SheetTitle className="sr-only">{label}</SheetTitle>
{open ? (
<div key={bodyKey} className="contents">
{contents}
</div>
) : null}
</SheetContent>
</Sheet>
)
}
return (
<AnimatePresence>
{open ? (
<motion.aside
key={bodyKey}
variants={panelSlide}
initial="hidden"
animate="visible"
exit="exit"
role="complementary"
aria-label={label}
data-testid={testId}
data-expanded={expanded || undefined}
className={cn(
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
expanded
? // Still a floating surface, only given the room code needs —
// and the canvas chrome keeps its lanes above and below.
"inset-x-4 bottom-16 top-16"
: "inset-y-4 right-4 w-[400px]",
)}
>
{contents}
</motion.aside>
) : null}
</AnimatePresence>
)
}