{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "flowchart", "type": "registry:component", "title": "Flowchart", "description": "Workflow trigger and condition steps on a dotted canvas.", "registryDependencies": [ "https://www.beautifului.dev/r/foundation.json" ], "files": [ { "path": "components/primitives/Flowchart.tsx", "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { useLayoutEffect } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FLOWCHART — an agent workflow on a dotted editor canvas.\n * Two steps: a Trigger card and an If/Else condition card,\n * joined by a measured connector. Cards drag anywhere on\n * the canvas; the connector follows. Condition chips open\n * real dropdowns (same menu as the PromptBar model picker).\n * ───────────────────────────────────────────────────────── */\n\nconst PURPLE = \"#9a5cff\";\nconst AMBER = \"#f09a2f\";\n\nconst mix = (hue: string, pct: number, base = \"var(--surface)\") =>\n `color-mix(in srgb, ${hue} ${pct}%, ${base})`;\n\n/* ── layout constants ── */\nconst PAD_Y = 24;\nconst ROW_GAP = 64;\nconst PILL_OFFSET = 30; // kind pill + gap above a card\n\nexport type StepNode = {\n id: string;\n row: number;\n x: number; // 0–1 center of the node\n w: number;\n kind?: { label: string; hue: string };\n hue?: string;\n title?: string;\n caption?: string;\n condition?: boolean; // renders the if/else chip rows instead\n};\n\nconst NODES: StepNode[] = [\n {\n id: \"trigger\",\n row: 0,\n x: 0.5,\n w: 300,\n kind: { label: \"Trigger\", hue: PURPLE },\n hue: PURPLE,\n title: \"New order created\",\n caption: \"Trigger when a new order is created\",\n },\n {\n id: \"cond\",\n row: 1,\n x: 0.5,\n w: 356,\n kind: { label: \"If / Else\", hue: AMBER },\n condition: true,\n },\n];\n\nconst EDGES = [{ from: \"trigger\", to: \"cond\" }];\n\n/* estimated heights for the first paint; measured immediately after */\nconst EST_H: Record = { trigger: 92, cond: 134 };\n\nconst PROPERTIES = [\"flavor\", \"topping\", \"size\", \"scoops\"];\nconst FLAVORS = [\n { name: \"Rocky Road\", tag: \"Classic\" },\n { name: \"Mint Chip\", tag: \"Classic\" },\n { name: \"Pistachio\", tag: \"Seasonal\" },\n { name: \"Bubblegum\", tag: \"Retro\" },\n];\nconst TOPPINGS = [\n { name: \"Brown butter bourbon brittle crunch\" },\n { name: \"Rainbow sprinkles\" },\n { name: \"Hot fudge\" },\n { name: \"Candied pecans\" },\n];\n\n/* ── icons ── */\nfunction ConeIcon({ size = 16 }: { size?: number }) {\n return (\n \n \n \n \n \n );\n}\n\nfunction Chevron() {\n return (\n \n \n \n );\n}\n\nfunction Handle() {\n return (\n \n {[3, 8, 13].flatMap((y) => [\n ,\n ,\n ])}\n \n );\n}\n\nfunction CheckIcon() {\n return (\n \n \n \n );\n}\n\n/* ── dropdown menu — same pattern as the PromptBar model picker ── */\nfunction Menu({\n items,\n value,\n width,\n align,\n onPick,\n}: {\n items: { name: string; tag?: string }[];\n value: string;\n width: string;\n align: \"left\" | \"right\";\n onPick: (name: string) => void;\n}) {\n const [hovered, setHovered] = useState(null);\n const rowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const [box, setBox] = useState<{ top: number; height: number } | null>(null);\n\n const valueIndex = items.findIndex((item) => item.name === value);\n useLayoutEffect(() => {\n const row = rowRefs.current[hovered ?? valueIndex];\n if (row) setBox({ top: row.offsetTop, height: row.offsetHeight });\n }, [hovered, valueIndex]);\n\n return (\n setHovered(null)}\n className={`absolute bottom-full z-20 mb-1.5 rounded-[10px] bg-surface p-1 shadow-raised ${width}\n ${align === \"right\" ? \"right-0\" : \"left-0\"}`}\n style={{\n animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\",\n transformOrigin: align === \"right\" ? \"bottom right\" : \"bottom left\",\n }}\n >\n \n {items.map((item, i) => (\n {\n rowRefs.current[i] = el;\n }}\n onMouseEnter={() => setHovered(i)}\n onClick={() => onPick(item.name)}\n className=\"relative z-10 flex h-7.5 w-full cursor-pointer items-center gap-2 rounded-[6px] px-2 text-left\"\n >\n {item.name}\n {item.tag && {item.tag}}\n \n \n \n \n ))}\n \n );\n}\n\n/* ── chips used inside the condition card ── */\nfunction SourceChip() {\n return (\n \n \n \n \n order\n \n );\n}\n\nfunction SelectChip({\n id,\n value,\n dot,\n items,\n width,\n align = \"left\",\n open,\n onToggle,\n onPick,\n}: {\n id: string;\n value: string;\n dot?: boolean;\n items: { name: string; tag?: string }[];\n width: string;\n align?: \"left\" | \"right\";\n open: boolean;\n onToggle: (id: string) => void;\n onPick: (id: string, name: string) => void;\n}) {\n return (\n \n onToggle(id)}\n className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 rounded-[6px] px-1.5\n text-[12px] font-medium text-ink transition-colors duration-100\n ${open ? \"bg-hover-2\" : \"bg-field hover:bg-hover-2\"}`}\n >\n {dot && }\n {value}\n \n \n {open && (\n onPick(id, name)}\n />\n )}\n \n );\n}\n\nfunction ConditionBody() {\n const [values, setValues] = useState>({\n prop1: \"flavor\",\n val1: \"Rocky Road\",\n prop2: \"topping\",\n val2: \"Brown butter bourbon brittle crunch\",\n });\n const [open, setOpen] = useState(null);\n\n /* click anywhere else closes the menu */\n useEffect(() => {\n if (!open) return;\n const close = (event: PointerEvent) => {\n if (!(event.target as Element).closest(\"[data-ui]\")) setOpen(null);\n };\n document.addEventListener(\"pointerdown\", close);\n return () => document.removeEventListener(\"pointerdown\", close);\n }, [open]);\n\n const toggle = (id: string) => setOpen((current) => (current === id ? null : id));\n const pick = (id: string, name: string) => {\n setValues((current) => ({ ...current, [id]: name }));\n setOpen(null);\n };\n\n const chip = (id: string, items: { name: string; tag?: string }[], width: string, extra?: object) => (\n \n );\n\n return (\n
\n
\n \n If\n \n {chip(\"prop1\", PROPERTIES.map((name) => ({ name })), \"w-36\")}\n is\n {chip(\"val1\", FLAVORS, \"w-44\", { dot: true, align: \"right\" })}\n
\n
\n \n and\n \n {chip(\"prop2\", PROPERTIES.map((name) => ({ name })), \"w-36\")}\n is\n \n {chip(\"val2\", TOPPINGS, \"w-64\", { dot: true })}\n \n
\n
\n );\n}\n\nfunction StepBody({ node }: { node: StepNode }) {\n return (\n
\n \n \n \n \n {node.title}\n {node.caption}\n \n
\n );\n}\n\n/* ── the canvas ── */\nexport default function Flowchart({ steps = NODES }: { steps?: StepNode[]; variant?: string } = {}) {\n const canvasRef = useRef(null);\n const nodeRefs = useRef(new Map());\n const [width, setWidth] = useState(0);\n const [heights, setHeights] = useState>(EST_H);\n const [selected, setSelected] = useState(null);\n const [offsets, setOffsets] = useState>({});\n const drag = useRef<{\n id: string;\n startX: number;\n startY: number;\n baseDx: number;\n baseDy: number;\n moved: boolean;\n } | null>(null);\n\n useLayoutEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n const measure = () => {\n setWidth(canvas.clientWidth);\n setHeights((prev) => {\n const next = { ...prev };\n let changed = false;\n nodeRefs.current.forEach((el, id) => {\n const h = el.offsetHeight;\n if (h && Math.abs(h - (next[id] ?? 0)) > 0.5) {\n next[id] = h;\n changed = true;\n }\n });\n return changed ? next : prev;\n });\n };\n\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(canvas);\n nodeRefs.current.forEach((el) => observer.observe(el));\n return () => observer.disconnect();\n }, []);\n\n /* rows → y offsets from measured node heights */\n const rows = [...new Set(steps.map((n) => n.row))].sort((a, b) => a - b);\n const rowH = rows.map((r) =>\n Math.max(...steps.filter((n) => n.row === r).map((n) => heights[n.id] ?? 90)),\n );\n const rowY: number[] = [];\n rows.forEach((_, i) => {\n rowY[i] = i === 0 ? PAD_Y : rowY[i - 1] + rowH[i - 1] + ROW_GAP;\n });\n const canvasH = rowY[rows.length - 1] + rowH[rows.length - 1] + PAD_Y;\n\n const cw = width || 480;\n const place = (n: StepNode) => {\n const w = Math.min(n.w, cw * 0.92);\n const off = offsets[n.id];\n return {\n w,\n cx: n.x * cw + (off?.dx ?? 0),\n top: rowY[rows.indexOf(n.row)] + (off?.dy ?? 0),\n };\n };\n\n /* card anchor points (pills sit above the card, so offset the top) */\n const anchors = (n: StepNode) => {\n const { cx, top } = place(n);\n return {\n top: { x: cx, y: top + (n.kind ? PILL_OFFSET : 0) },\n bottom: { x: cx, y: top + (heights[n.id] ?? 90) },\n };\n };\n\n const bezier = (edge: { from: string; to: string }) => {\n const from = anchors(steps.find((n) => n.id === edge.from)!).bottom;\n const to = anchors(steps.find((n) => n.id === edge.to)!).top;\n const k = Math.min(Math.max(Math.abs(to.y - from.y) * 0.55, 24), 84);\n return `M ${from.x} ${from.y} C ${from.x} ${from.y + k}, ${to.x} ${to.y - k}, ${to.x} ${to.y}`;\n };\n\n /* ── dragging ── */\n const onPointerDown = (node: StepNode) => (event: React.PointerEvent) => {\n if ((event.target as Element).closest(\"[data-ui]\")) return;\n const off = offsets[node.id];\n drag.current = {\n id: node.id,\n startX: event.clientX,\n startY: event.clientY,\n baseDx: off?.dx ?? 0,\n baseDy: off?.dy ?? 0,\n moved: false,\n };\n (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);\n };\n\n const onPointerMove = (node: StepNode) => (event: React.PointerEvent) => {\n const d = drag.current;\n if (!d || d.id !== node.id) return;\n const dx = d.baseDx + event.clientX - d.startX;\n const dy = d.baseDy + event.clientY - d.startY;\n if (!d.moved && Math.hypot(dx - d.baseDx, dy - d.baseDy) < 3) return;\n d.moved = true;\n\n /* keep the card inside the canvas */\n const { w } = place(node);\n const h = heights[node.id] ?? 90;\n const baseCx = node.x * cw;\n const baseTop = rowY[rows.indexOf(node.row)];\n const cx = Math.min(Math.max(baseCx + dx, w / 2 + 8), cw - w / 2 - 8);\n const top = Math.min(Math.max(baseTop + dy, 8), canvasH - h - 8);\n setOffsets((current) => ({ ...current, [node.id]: { dx: cx - baseCx, dy: top - baseTop } }));\n };\n\n const onPointerUp = (node: StepNode) => () => {\n const d = drag.current;\n if (d?.id === node.id) {\n /* a real drag shouldn't also toggle selection */\n if (d.moved) setTimeout(() => (drag.current = null), 0);\n else drag.current = null;\n }\n };\n\n const wasDragged = () => drag.current?.moved === true;\n\n const isLit = (edge: { from: string; to: string }) =>\n selected === edge.from || selected === edge.to;\n\n return (\n \n {/* connectors */}\n \n {EDGES.map((edge) => (\n \n ))}\n \n\n {/* nodes */}\n {steps.map((node) => {\n const { w, cx, top } = place(node);\n const active = selected === node.id;\n return (\n {\n if (el) nodeRefs.current.set(node.id, el);\n else nodeRefs.current.delete(node.id);\n }}\n onPointerDown={onPointerDown(node)}\n onPointerMove={onPointerMove(node)}\n onPointerUp={onPointerUp(node)}\n className=\"absolute flex -translate-x-1/2 touch-none flex-col items-start gap-1.5\"\n style={{ left: cx, top, width: w, zIndex: drag.current?.id === node.id ? 2 : 1 }}\n >\n {node.kind && (\n \n {node.kind.label}\n
\n )}\n {node.condition ? (\n
\n \n
\n ) : (\n {\n if (wasDragged()) return;\n setSelected(active ? null : node.id);\n }}\n aria-pressed={active}\n className={`w-full cursor-pointer rounded-[18px] bg-surface text-left outline-none\n transition-shadow duration-150 focus-visible:shadow-[0_0_0_1.5px_var(--accent)]\n ${\n active\n ? \"shadow-[0_0_0_1.5px_var(--accent),0_2px_10px_rgba(0,0,0,0.045)]\"\n : \"shadow-card hover:shadow-raised\"\n }`}\n >\n \n \n )}\n \n );\n })}\n \n );\n}\n", "type": "registry:component", "target": "components/primitives/Flowchart.tsx" } ] }