"use client"; import { Check, ChevronDown } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { Badge, type BadgeVariant } from "@/components/motion/badge"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; /** One dropdown chip inside a condition clause: its current value and the choices it offers. */ export interface ConditionField { value: string; options: string[]; } export interface ConditionClause { id: string; /** "if" for the first clause in a step, "and" for every clause after it. */ connector: "if" | "and"; /** The record a comparison reads from, shown as a small chip, e.g. "order". */ source: string; property: ConditionField; value: ConditionField; } export interface FlowStep { id: string; /** Small label above the card, such as "Trigger" or "If / Else". */ kind?: string; /** Color treatment for the kind pill and icon tint. Default "accent". */ kindVariant?: BadgeVariant; title?: string; description?: string; icon?: ReactNode; /** Renders an editable if/else condition list instead of the title and description. */ condition?: ConditionClause[]; /** Card width in px. Default 256, or 340 for a condition card. */ width?: number; } export interface FlowchartProps { /** Stacked top to bottom by default; drag a card anywhere on the canvas. */ steps: FlowStep[]; className?: string; } const GAP = 64; const PAD = 24; const EST_HEIGHT = 76; const CARD_WIDTH = 256; const CONDITION_WIDTH = 340; /** Pointer movement, in px, before a press counts as a drag rather than a click. */ const DRAG_THRESHOLD = 3; const OPEN = { duration: 0.15, ease: EASE_OUT } as const; const CLOSE = { duration: 0.1, ease: EASE_OUT } as const; type Offset = { dx: number; dy: number }; type DragState = { id: string; startX: number; startY: number; baseDx: number; baseDy: number; moved: boolean }; const ICON_TINT: Record = { neutral: "bg-muted text-muted-foreground", accent: "bg-accent/10 text-accent", success: "bg-success/15 text-success", warning: "bg-warning/15 text-warning", destructive: "bg-destructive/15 text-destructive", }; /** A small chip that opens an upward dropdown, used for the property and value in a condition clause. */ function ChipSelect({ value, options, align = "left", onChange, }: { value: string; options: string[]; align?: "left" | "right"; onChange: (value: string) => void; }) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const reduce = useReducedMotion(); useEffect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; window.addEventListener("pointerdown", onPointerDown); return () => window.removeEventListener("pointerdown", onPointerDown); }, [open]); return (
{options.map((option) => ( ))}
); } /** The if/else editor body: one row per clause, each a "connector, source, property is value" line. */ function ConditionBody({ clauses }: { clauses: ConditionClause[] }) { const [selection, setSelection] = useState>(() => Object.fromEntries(clauses.map((clause) => [clause.id, { property: clause.property.value, value: clause.value.value }])), ); return (
{clauses.map((clause) => (
{clause.connector === "if" ? "If" : "and"} {clause.source} setSelection((current) => ({ ...current, [clause.id]: { ...current[clause.id], property: next } }))} /> is setSelection((current) => ({ ...current, [clause.id]: { ...current[clause.id], value: next } }))} />
))}
); } /** * A sequence of steps on a dotted canvas, connected by curves that measure * the actual rendered cards. Each card can be dragged anywhere on the * canvas and the connector follows it live; click a card (without dragging * it) to light up the connectors on either side of it. A step can also * render as an if/else condition editor, with its property and value chips * opening real dropdowns, instead of the usual title and description. */ export function Flowchart({ steps, className }: FlowchartProps) { const containerRef = useRef(null); const cardRefs = useRef<(HTMLElement | null)[]>([]); const [containerWidth, setContainerWidth] = useState(0); const [heights, setHeights] = useState(() => steps.map(() => EST_HEIGHT)); const [offsets, setOffsets] = useState>({}); const [selected, setSelected] = useState(null); const dragRef = useRef(null); // steps.length is never read directly, but a changed step count means new // cards to observe, so the effect needs to re-run and re-attach. // biome-ignore lint/correctness/useExhaustiveDependencies: steps.length is intentional, see comment above. useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const measure = () => { setContainerWidth(container.clientWidth); setHeights((previous) => cardRefs.current.map((card, index) => { const measured = card?.offsetHeight; return measured && measured > 0 ? measured : (previous[index] ?? EST_HEIGHT); }), ); }; measure(); const observer = new ResizeObserver(measure); observer.observe(container); for (const card of cardRefs.current) { if (card) observer.observe(card); } return () => observer.disconnect(); }, [steps.length]); const widths = steps.map((step) => { const base = step.width ?? (step.condition ? CONDITION_WIDTH : CARD_WIDTH); return containerWidth > 0 ? Math.min(base, containerWidth * 0.92) : base; }); // Where each card sits before any dragging: stacked with a gap for the connector to arc through. const baseTops: number[] = []; steps.forEach((_, index) => { baseTops[index] = index === 0 ? PAD : baseTops[index - 1] + heights[index - 1] + GAP; }); const canvasHeight = (baseTops.at(-1) ?? PAD) + (heights.at(-1) ?? EST_HEIGHT) + PAD; const centerX = containerWidth / 2; const positionOf = (index: number) => { const offset = offsets[steps[index].id]; return { x: centerX + (offset?.dx ?? 0), top: baseTops[index] + (offset?.dy ?? 0) }; }; const onPointerDown = (id: string) => (event: ReactPointerEvent) => { // A press on a chip's own dropdown should open it, not drag the card. if ((event.target as Element).closest("[data-no-drag]")) return; const offset = offsets[id]; dragRef.current = { id, startX: event.clientX, startY: event.clientY, baseDx: offset?.dx ?? 0, baseDy: offset?.dy ?? 0, moved: false, }; event.currentTarget.setPointerCapture(event.pointerId); }; const onPointerMove = (id: string, index: number) => (event: ReactPointerEvent) => { const drag = dragRef.current; if (!drag || drag.id !== id) return; const dx = drag.baseDx + event.clientX - drag.startX; const dy = drag.baseDy + event.clientY - drag.startY; if (!drag.moved && Math.hypot(dx - drag.baseDx, dy - drag.baseDy) < DRAG_THRESHOLD) return; drag.moved = true; // Keep the card inside the canvas. const halfWidth = widths[index] / 2; const clampedDx = Math.min(Math.max(dx, halfWidth + PAD - centerX), containerWidth - halfWidth - PAD - centerX); const height = heights[index] ?? EST_HEIGHT; const clampedDy = Math.min( Math.max(dy, PAD - baseTops[index]), canvasHeight - PAD - height - baseTops[index], ); setOffsets((current) => ({ ...current, [id]: { dx: clampedDx, dy: clampedDy } })); }; const onPointerUp = (id: string) => () => { const drag = dragRef.current; if (drag?.id !== id) return; // A real drag shouldn't also toggle selection on release, but the click // still needs to see `moved`, so clear the ref one tick later. if (drag.moved) setTimeout(() => { dragRef.current = null; }, 0); else dragRef.current = null; }; const toggleSelected = (id: string) => setSelected((current) => (current === id ? null : id)); const onCardClick = (id: string) => (event: ReactMouseEvent) => { if ((event.target as Element).closest("[data-no-drag]")) return; if (dragRef.current?.moved) return; toggleSelected(id); }; return (
{steps.map((step, index) => { const { x, top } = positionOf(index); const active = selected === step.id; const variant = step.kindVariant ?? "accent"; return (
{ cardRefs.current[index] = el; }} onPointerDown={onPointerDown(step.id)} onPointerMove={onPointerMove(step.id, index)} onPointerUp={onPointerUp(step.id)} className="absolute flex -translate-x-1/2 cursor-grab touch-none flex-col items-center gap-1.5 active:cursor-grabbing" style={{ left: x, top, width: widths[index], zIndex: dragRef.current?.id === step.id ? 2 : 1 }} > {step.kind ? {step.kind} : null} {step.condition ? ( // biome-ignore lint/a11y/useSemanticElements: a )}
); })}
); }