Files
app/frontend/src/components/Flow/SidePanel.tsx
T
Melvin Strobl 037df4c75f Give the pulse its own duration, and let the editor fill the screen
The shared duration tokens had been stretched to slow the pulses down, which
also slowed every panel and dock animation and left `motion.ts` out of step
with the CSS. Both are back to 200/300 ms, and the pulses use a new
`--duration-pulse` (500 ms): a message arriving is a signal, not a state
change, and it is the only thing here that should linger. The edge pulse read
its own 300 ms constant, so it now takes the token through `motion.ts` and the
two sides cannot drift again.

The code editor also expands: the same node panel fills the content region
beside the sidebar, the canvas chrome steps aside rather than floating on top,
and the settings above the editor keep a readable width.
2026-08-15 20:03:21 +02:00

147 lines
4.0 KiB
TypeScript

import { X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import type { ReactNode } from "react"
import { useEffect } from "react"
import { Button } from "@/components/ui/button"
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"
/**
* 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
/** Fill the content area instead of floating beside 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"
// 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 border border-border bg-card/80 shadow-e2 backdrop-blur-md",
expanded
? // Fills the content region, which already starts after the sidebar.
"inset-0 rounded-none"
: "inset-y-4 right-4 w-[400px] rounded-lg",
)}
>
{contents}
</motion.aside>
) : null}
</AnimatePresence>
)
}