Files
inkreach-agent/docs/ui-ref/beautifului/flowchart.json
T

18 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"$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<string, number> = { 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 <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d=\"m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11\" />\n <path d=\"M17 7A5 5 0 0 0 7 7\" />\n <path d=\"M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4\" />\n </svg>\n );\n}\n\nfunction Chevron() {\n return (\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"shrink-0 text-ink-3\">\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n );\n}\n\nfunction Handle() {\n return (\n <svg width=\"10\" height=\"16\" viewBox=\"0 0 10 16\" className=\"shrink-0 cursor-grab text-ink-3/70\">\n {[3, 8, 13].flatMap((y) => [\n <circle key={`l${y}`} cx=\"3\" cy={y} r=\"1.1\" fill=\"currentColor\" />,\n <circle key={`r${y}`} cx=\"7.5\" cy={y} r=\"1.1\" fill=\"currentColor\" />,\n ])}\n </svg>\n );\n}\n\nfunction CheckIcon() {\n return (\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d=\"M20 6L9 17l-5-5\" />\n </svg>\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<number | null>(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 <div\n onMouseLeave={() => 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 <span\n aria-hidden\n className=\"pointer-events-none absolute inset-x-1 rounded-[6px] bg-hover\"\n style={{\n top: box?.top ?? 0,\n height: box?.height ?? 0,\n opacity: box && hovered !== null ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {items.map((item, i) => (\n <button\n key={item.name}\n type=\"button\"\n ref={(el) => {\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 <span className=\"min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink\">{item.name}</span>\n {item.tag && <span className=\"shrink-0 text-[11px] text-ink-3\">{item.tag}</span>}\n <span className={`shrink-0 text-ink ${item.name === value ? \"\" : \"invisible\"}`}>\n <CheckIcon />\n </span>\n </button>\n ))}\n </div>\n );\n}\n\n/* ── chips used inside the condition card ── */\nfunction SourceChip() {\n return (\n <span\n data-ui\n className=\"inline-flex h-6 shrink-0 items-center gap-1 rounded-[6px] bg-surface px-1.5 text-[12px] font-medium text-ink shadow-btn\"\n >\n <span className=\"text-ink-2\">\n <ConeIcon size={12} />\n </span>\n order\n </span>\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 <span data-ui className=\"relative inline-flex min-w-0\">\n <button\n type=\"button\"\n aria-expanded={open}\n onClick={() => 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 && <span className=\"size-1.5 shrink-0 rounded-full\" style={{ background: AMBER }} />}\n <span className=\"min-w-0 truncate\">{value}</span>\n <Chevron />\n </button>\n {open && (\n <Menu\n items={items}\n value={value}\n width={width}\n align={align}\n onPick={(name) => onPick(id, name)}\n />\n )}\n </span>\n );\n}\n\nfunction ConditionBody() {\n const [values, setValues] = useState<Record<string, string>>({\n prop1: \"flavor\",\n val1: \"Rocky Road\",\n prop2: \"topping\",\n val2: \"Brown butter bourbon brittle crunch\",\n });\n const [open, setOpen] = useState<string | null>(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 <SelectChip\n id={id}\n value={values[id]}\n items={items}\n width={width}\n open={open === id}\n onToggle={toggle}\n onPick={pick}\n {...extra}\n />\n );\n\n return (\n <div className=\"flex flex-col gap-1.5 px-3 py-2.5\">\n <div className=\"flex min-w-0 items-center gap-1.5\">\n <Handle />\n <span className=\"w-7 text-[12.5px] text-ink-2\">If</span>\n <SourceChip />\n {chip(\"prop1\", PROPERTIES.map((name) => ({ name })), \"w-36\")}\n <span className=\"text-[12.5px] text-ink-2\">is</span>\n {chip(\"val1\", FLAVORS, \"w-44\", { dot: true, align: \"right\" })}\n </div>\n <div className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1.5\">\n <Handle />\n <span className=\"w-7 text-[12.5px] text-ink-2\">and</span>\n <SourceChip />\n {chip(\"prop2\", PROPERTIES.map((name) => ({ name })), \"w-36\")}\n <span className=\"text-[12.5px] text-ink-2\">is</span>\n <span className=\"max-w-full pl-[49px]\">\n {chip(\"val2\", TOPPINGS, \"w-64\", { dot: true })}\n </span>\n </div>\n </div>\n );\n}\n\nfunction StepBody({ node }: { node: StepNode }) {\n return (\n <div className=\"flex items-center gap-2.5 p-2.5\">\n <span\n className=\"flex size-9 shrink-0 items-center justify-center rounded-[8px]\"\n style={{\n background: mix(node.hue!, 12),\n color: node.hue,\n boxShadow: `0 0 0 1px ${mix(node.hue!, 20)}`,\n }}\n >\n <ConeIcon />\n </span>\n <span className=\"min-w-0 text-left\">\n <span className=\"block truncate text-[13px] font-semibold leading-tight text-ink\">{node.title}</span>\n <span className=\"mt-0.5 block text-[12px] leading-snug text-ink-2\">{node.caption}</span>\n </span>\n </div>\n );\n}\n\n/* ── the canvas ── */\nexport default function Flowchart({ steps = NODES }: { steps?: StepNode[]; variant?: string } = {}) {\n const canvasRef = useRef<HTMLDivElement>(null);\n const nodeRefs = useRef(new Map<string, HTMLElement>());\n const [width, setWidth] = useState(0);\n const [heights, setHeights] = useState<Record<string, number>>(EST_H);\n const [selected, setSelected] = useState<string | null>(null);\n const [offsets, setOffsets] = useState<Record<string, { dx: number; dy: number }>>({});\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<HTMLDivElement>) => {\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<HTMLDivElement>) => {\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 <div\n ref={canvasRef}\n className=\"relative w-full select-none overflow-hidden rounded-card bg-page shadow-hairline\"\n style={{\n height: canvasH,\n backgroundImage: \"radial-gradient(var(--line-strong) 1px, transparent 1.25px)\",\n backgroundSize: \"22px 22px\",\n backgroundPosition: \"center\",\n }}\n >\n {/* connectors */}\n <svg width={cw} height={canvasH} className=\"pointer-events-none absolute inset-0\">\n {EDGES.map((edge) => (\n <path\n key={`${edge.from}-${edge.to}`}\n d={bezier(edge)}\n fill=\"none\"\n stroke={isLit(edge) ? \"var(--accent)\" : \"var(--line-strong)\"}\n strokeWidth=\"1.25\"\n className=\"transition-[stroke] duration-150\"\n />\n ))}\n </svg>\n\n {/* nodes */}\n {steps.map((node) => {\n const { w, cx, top } = place(node);\n const active = selected === node.id;\n return (\n <div\n key={node.id}\n ref={(el) => {\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 <span\n className=\"inline-flex h-6 items-center rounded-[6px] px-2 text-[11.5px] font-medium\"\n style={{\n background: mix(node.kind.hue, 14, \"var(--page)\"),\n color: mix(node.kind.hue, 80, \"var(--ink)\"),\n }}\n >\n {node.kind.label}\n </span>\n )}\n {node.condition ? (\n <div className=\"w-full rounded-[18px] bg-surface shadow-card transition-shadow duration-150 hover:shadow-raised\">\n <ConditionBody />\n </div>\n ) : (\n <button\n type=\"button\"\n onClick={() => {\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 <StepBody node={node} />\n </button>\n )}\n </div>\n );\n })}\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/Flowchart.tsx"
}
]
}