@elabs-ai/components-flow 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/README.md +2 -1
  2. package/dist/canvas-shell/canvas-shell.js +13 -0
  3. package/dist/canvas-shell/canvas-shell.js.map +1 -0
  4. package/dist/canvas-shell/clamped-fit-offset.js +8 -0
  5. package/dist/canvas-shell/clamped-fit-offset.js.map +1 -0
  6. package/dist/canvas-shell/index.js +14 -0
  7. package/dist/canvas-shell/index.js.map +1 -0
  8. package/dist/canvas-shell/use-measured-nodes.js +8 -0
  9. package/dist/canvas-shell/use-measured-nodes.js.map +1 -0
  10. package/dist/chunk-3BTU2VWZ.js +42 -0
  11. package/dist/chunk-3BTU2VWZ.js.map +1 -0
  12. package/dist/chunk-3LK6OOXE.js +2 -0
  13. package/dist/chunk-3LK6OOXE.js.map +1 -0
  14. package/dist/chunk-3M3FQAD4.js +105 -0
  15. package/dist/chunk-3M3FQAD4.js.map +1 -0
  16. package/dist/chunk-3Y5TCGC3.js +2 -0
  17. package/dist/chunk-3Y5TCGC3.js.map +1 -0
  18. package/dist/chunk-4VYHZM3S.js +108 -0
  19. package/dist/chunk-4VYHZM3S.js.map +1 -0
  20. package/dist/chunk-5TVVM4GR.js +45 -0
  21. package/dist/chunk-5TVVM4GR.js.map +1 -0
  22. package/dist/chunk-5XT73MQQ.js +2 -0
  23. package/dist/chunk-5XT73MQQ.js.map +1 -0
  24. package/dist/chunk-6LQD5Y5R.js +24 -0
  25. package/dist/chunk-6LQD5Y5R.js.map +1 -0
  26. package/dist/chunk-6SR26QY2.js +18 -0
  27. package/dist/chunk-6SR26QY2.js.map +1 -0
  28. package/dist/chunk-75JJK6BH.js +157 -0
  29. package/dist/chunk-75JJK6BH.js.map +1 -0
  30. package/dist/chunk-A66JBW4X.js +2 -0
  31. package/dist/chunk-A66JBW4X.js.map +1 -0
  32. package/dist/chunk-A7AFM3LI.js +72 -0
  33. package/dist/chunk-A7AFM3LI.js.map +1 -0
  34. package/dist/chunk-ADHLQF56.js +2 -0
  35. package/dist/chunk-ADHLQF56.js.map +1 -0
  36. package/dist/chunk-ATO4LXJF.js +75 -0
  37. package/dist/chunk-ATO4LXJF.js.map +1 -0
  38. package/dist/chunk-ATXVXTMZ.js +227 -0
  39. package/dist/chunk-ATXVXTMZ.js.map +1 -0
  40. package/dist/chunk-B6IUPZU7.js +2 -0
  41. package/dist/chunk-B6IUPZU7.js.map +1 -0
  42. package/dist/chunk-BJ7UMCD3.js +53 -0
  43. package/dist/chunk-BJ7UMCD3.js.map +1 -0
  44. package/dist/chunk-BWGYV4JZ.js +2 -0
  45. package/dist/chunk-BWGYV4JZ.js.map +1 -0
  46. package/dist/chunk-CWNO63LQ.js +109 -0
  47. package/dist/chunk-CWNO63LQ.js.map +1 -0
  48. package/dist/chunk-DHPUXKCZ.js +76 -0
  49. package/dist/chunk-DHPUXKCZ.js.map +1 -0
  50. package/dist/chunk-DNX7G3KA.js +185 -0
  51. package/dist/chunk-DNX7G3KA.js.map +1 -0
  52. package/dist/chunk-DNZRAJAQ.js +41 -0
  53. package/dist/chunk-DNZRAJAQ.js.map +1 -0
  54. package/dist/chunk-ELCLRVD4.js +2 -0
  55. package/dist/chunk-ELCLRVD4.js.map +1 -0
  56. package/dist/chunk-ELOADZSX.js +2 -0
  57. package/dist/chunk-ELOADZSX.js.map +1 -0
  58. package/dist/chunk-EUNV2MR5.js +21 -0
  59. package/dist/chunk-EUNV2MR5.js.map +1 -0
  60. package/dist/chunk-FAFQ5KLB.js +43 -0
  61. package/dist/chunk-FAFQ5KLB.js.map +1 -0
  62. package/dist/chunk-FIRO6Y3R.js +128 -0
  63. package/dist/chunk-FIRO6Y3R.js.map +1 -0
  64. package/dist/chunk-GE73WQUK.js +2 -0
  65. package/dist/chunk-GE73WQUK.js.map +1 -0
  66. package/dist/chunk-GIARAOU6.js +39 -0
  67. package/dist/chunk-GIARAOU6.js.map +1 -0
  68. package/dist/chunk-HTLTCCFA.js +73 -0
  69. package/dist/chunk-HTLTCCFA.js.map +1 -0
  70. package/dist/chunk-I53W3CFN.js +56 -0
  71. package/dist/chunk-I53W3CFN.js.map +1 -0
  72. package/dist/chunk-IJVFPUXO.js +2 -0
  73. package/dist/chunk-IJVFPUXO.js.map +1 -0
  74. package/dist/chunk-JUPMOISE.js +51 -0
  75. package/dist/chunk-JUPMOISE.js.map +1 -0
  76. package/dist/chunk-K5RPSVO6.js +167 -0
  77. package/dist/chunk-K5RPSVO6.js.map +1 -0
  78. package/dist/chunk-K6U27YJ6.js +78 -0
  79. package/dist/chunk-K6U27YJ6.js.map +1 -0
  80. package/dist/chunk-KDVMGMAK.js +2 -0
  81. package/dist/chunk-KDVMGMAK.js.map +1 -0
  82. package/dist/chunk-LQOZCOK3.js +74 -0
  83. package/dist/chunk-LQOZCOK3.js.map +1 -0
  84. package/dist/chunk-NAQWNX2A.js +102 -0
  85. package/dist/chunk-NAQWNX2A.js.map +1 -0
  86. package/dist/chunk-NVJVWGUS.js +152 -0
  87. package/dist/chunk-NVJVWGUS.js.map +1 -0
  88. package/dist/chunk-O2F3R3PV.js +2 -0
  89. package/dist/chunk-O2F3R3PV.js.map +1 -0
  90. package/dist/chunk-OMFQHMA6.js +2 -0
  91. package/dist/chunk-OMFQHMA6.js.map +1 -0
  92. package/dist/chunk-PE6JXVOX.js +2 -0
  93. package/dist/chunk-PE6JXVOX.js.map +1 -0
  94. package/dist/chunk-ROJEIIA4.js +52 -0
  95. package/dist/chunk-ROJEIIA4.js.map +1 -0
  96. package/dist/chunk-ROYAQKJA.js +9 -0
  97. package/dist/chunk-ROYAQKJA.js.map +1 -0
  98. package/dist/chunk-SHCZK4DO.js +45 -0
  99. package/dist/chunk-SHCZK4DO.js.map +1 -0
  100. package/dist/chunk-SXXJV6YJ.js +79 -0
  101. package/dist/chunk-SXXJV6YJ.js.map +1 -0
  102. package/dist/chunk-T7S7NJFD.js +287 -0
  103. package/dist/chunk-T7S7NJFD.js.map +1 -0
  104. package/dist/chunk-TEZ3OXAY.js +63 -0
  105. package/dist/chunk-TEZ3OXAY.js.map +1 -0
  106. package/dist/chunk-TGWPM2CG.js +199 -0
  107. package/dist/chunk-TGWPM2CG.js.map +1 -0
  108. package/dist/chunk-TSB6RE7L.js +2 -0
  109. package/dist/chunk-TSB6RE7L.js.map +1 -0
  110. package/dist/chunk-UPV2MT4O.js +2 -0
  111. package/dist/chunk-UPV2MT4O.js.map +1 -0
  112. package/dist/chunk-UZJZY4A5.js +2 -0
  113. package/dist/chunk-UZJZY4A5.js.map +1 -0
  114. package/dist/chunk-W3TQMUGX.js +2 -0
  115. package/dist/chunk-W3TQMUGX.js.map +1 -0
  116. package/dist/chunk-WN46RIOC.js +29 -0
  117. package/dist/chunk-WN46RIOC.js.map +1 -0
  118. package/dist/chunk-XZFE2YYH.js +70 -0
  119. package/dist/chunk-XZFE2YYH.js.map +1 -0
  120. package/dist/chunk-YG3KXTZW.js +2 -0
  121. package/dist/chunk-YG3KXTZW.js.map +1 -0
  122. package/dist/chunk-ZATVF42E.js +65 -0
  123. package/dist/chunk-ZATVF42E.js.map +1 -0
  124. package/dist/chunk-ZXZTLQIX.js +19 -0
  125. package/dist/chunk-ZXZTLQIX.js.map +1 -0
  126. package/dist/flow-button-edge/flow-button-edge.js +10 -0
  127. package/dist/flow-button-edge/flow-button-edge.js.map +1 -0
  128. package/dist/flow-button-edge/index.js +11 -0
  129. package/dist/flow-button-edge/index.js.map +1 -0
  130. package/dist/flow-edge/flow-edge.js +10 -0
  131. package/dist/flow-edge/flow-edge.js.map +1 -0
  132. package/dist/flow-edge/index.js +11 -0
  133. package/dist/flow-edge/index.js.map +1 -0
  134. package/dist/flow-edge-path/flow-edge-path.js +12 -0
  135. package/dist/flow-edge-path/flow-edge-path.js.map +1 -0
  136. package/dist/flow-edge-path/index.js +13 -0
  137. package/dist/flow-edge-path/index.js.map +1 -0
  138. package/dist/flow-edge-tokens/edge-path-sampler.js +10 -0
  139. package/dist/flow-edge-tokens/edge-path-sampler.js.map +1 -0
  140. package/dist/flow-edge-tokens/flow-edge-tokens.js +12 -0
  141. package/dist/flow-edge-tokens/flow-edge-tokens.js.map +1 -0
  142. package/dist/flow-edge-tokens/index.js +17 -0
  143. package/dist/flow-edge-tokens/index.js.map +1 -0
  144. package/dist/flow-floating-edge/floating-edge-geometry.js +8 -0
  145. package/dist/flow-floating-edge/floating-edge-geometry.js.map +1 -0
  146. package/dist/flow-floating-edge/flow-floating-edge.js +11 -0
  147. package/dist/flow-floating-edge/flow-floating-edge.js.map +1 -0
  148. package/dist/flow-floating-edge/index.js +15 -0
  149. package/dist/flow-floating-edge/index.js.map +1 -0
  150. package/dist/flow-group-node/flow-group-node.js +12 -0
  151. package/dist/flow-group-node/flow-group-node.js.map +1 -0
  152. package/dist/flow-group-node/index.js +13 -0
  153. package/dist/flow-group-node/index.js.map +1 -0
  154. package/dist/flow-handle/flow-handle-anchor.js +8 -0
  155. package/dist/flow-handle/flow-handle-anchor.js.map +1 -0
  156. package/dist/flow-handle/index.js +9 -0
  157. package/dist/flow-handle/index.js.map +1 -0
  158. package/dist/flow-layout/backbone.js +8 -0
  159. package/dist/flow-layout/backbone.js.map +1 -0
  160. package/dist/flow-layout/flow-layout.js +10 -0
  161. package/dist/flow-layout/flow-layout.js.map +1 -0
  162. package/dist/flow-layout/index.js +21 -0
  163. package/dist/flow-layout/index.js.map +1 -0
  164. package/dist/flow-layout/layout-flow-elk.js +9 -0
  165. package/dist/flow-layout/layout-flow-elk.js.map +1 -0
  166. package/dist/flow-layout/layout-graph.js +9 -0
  167. package/dist/flow-layout/layout-graph.js.map +1 -0
  168. package/dist/flow-layout/use-auto-layout.js +10 -0
  169. package/dist/flow-layout/use-auto-layout.js.map +1 -0
  170. package/dist/flow-layout/use-flow-layout.js +9 -0
  171. package/dist/flow-layout/use-flow-layout.js.map +1 -0
  172. package/dist/flow-mini-map/flow-mini-map.js +8 -0
  173. package/dist/flow-mini-map/flow-mini-map.js.map +1 -0
  174. package/dist/flow-mini-map/index.js +9 -0
  175. package/dist/flow-mini-map/index.js.map +1 -0
  176. package/dist/flow-node/flow-node.js +11 -0
  177. package/dist/flow-node/flow-node.js.map +1 -0
  178. package/dist/flow-node/index.js +12 -0
  179. package/dist/flow-node/index.js.map +1 -0
  180. package/dist/flow-placeholder-node/flow-placeholder-node.js +9 -0
  181. package/dist/flow-placeholder-node/flow-placeholder-node.js.map +1 -0
  182. package/dist/flow-placeholder-node/index.js +10 -0
  183. package/dist/flow-placeholder-node/index.js.map +1 -0
  184. package/dist/flow-self-loop-edge/flow-self-loop-edge.js +20 -0
  185. package/dist/flow-self-loop-edge/flow-self-loop-edge.js.map +1 -0
  186. package/dist/flow-self-loop-edge/index.js +28 -0
  187. package/dist/flow-self-loop-edge/index.js.map +1 -0
  188. package/dist/flow-self-loop-edge/self-loop-geometry.js +12 -0
  189. package/dist/flow-self-loop-edge/self-loop-geometry.js.map +1 -0
  190. package/dist/flow-smart-edge/flow-smart-edge.js +11 -0
  191. package/dist/flow-smart-edge/flow-smart-edge.js.map +1 -0
  192. package/dist/flow-smart-edge/index.js +25 -0
  193. package/dist/flow-smart-edge/index.js.map +1 -0
  194. package/dist/flow-smart-edge/smart-edge-geometry.js +18 -0
  195. package/dist/flow-smart-edge/smart-edge-geometry.js.map +1 -0
  196. package/dist/flow-weighted-edge/back-edge-geometry.js +8 -0
  197. package/dist/flow-weighted-edge/back-edge-geometry.js.map +1 -0
  198. package/dist/flow-weighted-edge/edge-aria.js +10 -0
  199. package/dist/flow-weighted-edge/edge-aria.js.map +1 -0
  200. package/dist/flow-weighted-edge/edge-label-pill.js +8 -0
  201. package/dist/flow-weighted-edge/edge-label-pill.js.map +1 -0
  202. package/dist/flow-weighted-edge/flow-weighted-edge.js +16 -0
  203. package/dist/flow-weighted-edge/flow-weighted-edge.js.map +1 -0
  204. package/dist/flow-weighted-edge/index.js +34 -0
  205. package/dist/flow-weighted-edge/index.js.map +1 -0
  206. package/dist/flow-weighted-edge/weight-scale.js +10 -0
  207. package/dist/flow-weighted-edge/weight-scale.js.map +1 -0
  208. package/dist/helper-lines/get-helper-lines.js +8 -0
  209. package/dist/helper-lines/get-helper-lines.js.map +1 -0
  210. package/dist/helper-lines/helper-lines.js +9 -0
  211. package/dist/helper-lines/helper-lines.js.map +1 -0
  212. package/dist/helper-lines/index.js +17 -0
  213. package/dist/helper-lines/index.js.map +1 -0
  214. package/dist/helper-lines/use-helper-lines.js +10 -0
  215. package/dist/helper-lines/use-helper-lines.js.map +1 -0
  216. package/dist/index.d.ts +241 -2
  217. package/dist/index.js +167 -2306
  218. package/dist/index.js.map +1 -1
  219. package/dist/inspector-panel/index.js +9 -0
  220. package/dist/inspector-panel/index.js.map +1 -0
  221. package/dist/inspector-panel/inspector-panel.js +8 -0
  222. package/dist/inspector-panel/inspector-panel.js.map +1 -0
  223. package/dist/legend/index.js +10 -0
  224. package/dist/legend/index.js.map +1 -0
  225. package/dist/legend/legend.js +9 -0
  226. package/dist/legend/legend.js.map +1 -0
  227. package/dist/use-flow-groups/group-operations.js +20 -0
  228. package/dist/use-flow-groups/group-operations.js.map +1 -0
  229. package/dist/use-flow-groups/index.js +25 -0
  230. package/dist/use-flow-groups/index.js.map +1 -0
  231. package/dist/use-flow-groups/use-flow-groups.js +9 -0
  232. package/dist/use-flow-groups/use-flow-groups.js.map +1 -0
  233. package/dist/zoom-controls/index.js +9 -0
  234. package/dist/zoom-controls/index.js.map +1 -0
  235. package/dist/zoom-controls/zoom-controls.js +8 -0
  236. package/dist/zoom-controls/zoom-controls.js.map +1 -0
  237. package/package.json +14 -7
  238. package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
  239. package/src/__contract__/legend.contract.test.tsx +49 -0
  240. package/src/canvas-shell/canvas-shell.stories.tsx +1 -0
  241. package/src/canvas-shell/canvas-shell.tsx +51 -4
  242. package/src/canvas-shell/clamped-fit-offset.test.ts +25 -0
  243. package/src/canvas-shell/clamped-fit-offset.ts +30 -0
  244. package/src/custom-nodes/custom-nodes.stories.tsx +718 -0
  245. package/src/flow-edge-tokens/edge-path-sampler.test.ts +108 -0
  246. package/src/flow-edge-tokens/edge-path-sampler.ts +362 -0
  247. package/src/flow-edge-tokens/flow-edge-tokens.stories.tsx +312 -0
  248. package/src/flow-edge-tokens/flow-edge-tokens.test.tsx +115 -0
  249. package/src/flow-edge-tokens/flow-edge-tokens.tsx +94 -0
  250. package/src/flow-edge-tokens/index.ts +12 -0
  251. package/src/flow-layout/backbone.test.ts +88 -0
  252. package/src/flow-layout/backbone.ts +58 -0
  253. package/src/flow-layout/layout-flow-elk.stories.tsx +154 -0
  254. package/src/flow-layout/layout-flow-elk.test.ts +152 -0
  255. package/src/flow-layout/layout-flow-elk.ts +399 -0
  256. package/src/flow-mini-map/flow-mini-map.stories.tsx +20 -0
  257. package/src/flow-node/flow-node.stories.tsx +40 -1
  258. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +38 -0
  259. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +7 -0
  260. package/src/flow-weighted-edge/edge-label-pill.test.tsx +20 -0
  261. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +1 -0
  262. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +40 -0
  263. package/src/flow-weighted-edge/flow-weighted-edge.tsx +7 -0
  264. package/src/index.ts +13 -0
  265. package/src/inspector-panel/inspector-panel.tsx +9 -6
  266. package/src/legend/legend.test.tsx +11 -0
  267. package/src/legend/legend.tsx +15 -7
  268. package/src/templates-flow-workspace.stories.tsx +7 -2
@@ -0,0 +1,718 @@
1
+ /**
2
+ * Custom nodes — how to write your OWN node type on the branded canvas.
3
+ *
4
+ * Nothing here is new package API. Every node below is a plain React component
5
+ * registered in `nodeTypes`, built from React Flow's `Handle`/`NodeToolbar`/
6
+ * `NodeResizer`, `@elabs-ai/components-ui` primitives and the three conventions
7
+ * `FlowNode` itself follows. Copy the one closest to what you need.
8
+ *
9
+ * The three conventions (see `nodeCardClassName` / `handleClassName` below):
10
+ *
11
+ * 1. The painted card IS the node box. Handles live inside it, so their dots sit
12
+ * on its border. Extra rows go INSIDE the card, never beside it.
13
+ * 2. Every `<Handle>` carries `FLOW_HANDLE_ANCHOR_CLASS` — a connector dot is a
14
+ * measurement anchor and must never be mid-transition when React Flow reads it.
15
+ * 3. React Flow focuses ITS wrapper, not your div, so the focus indicator is the
16
+ * proxied flavour: `[[data-id]:focus-visible_&]:focus-ring-static`. `selected`
17
+ * paints a ring of its own; the two never merge.
18
+ *
19
+ * Controls inside a node (inputs, switches, buttons) carry React Flow's `nodrag`
20
+ * class so a click or a text selection does not start a node drag.
21
+ */
22
+ import type { Meta, StoryObj } from "@storybook/react-vite";
23
+ import "@xyflow/react/dist/style.css";
24
+ import { type ReactNode } from "react";
25
+ import {
26
+ Handle,
27
+ NodeResizer,
28
+ NodeToolbar,
29
+ Position,
30
+ useReactFlow,
31
+ type Edge,
32
+ type Node,
33
+ type NodeProps,
34
+ } from "@xyflow/react";
35
+ import {
36
+ Braces,
37
+ Copy,
38
+ FileSpreadsheet,
39
+ Filter,
40
+ Mail,
41
+ Play,
42
+ StickyNote,
43
+ Trash2,
44
+ Webhook,
45
+ } from "lucide-react";
46
+ import {
47
+ Badge,
48
+ Button,
49
+ IconButton,
50
+ Input,
51
+ Label,
52
+ Meter,
53
+ StatusBadge,
54
+ Switch,
55
+ cn,
56
+ } from "@elabs-ai/components-ui";
57
+ import { CanvasShell } from "../canvas-shell";
58
+ import { FlowEdge } from "../flow-edge";
59
+ import { FLOW_HANDLE_ANCHOR_CLASS } from "../flow-handle";
60
+ import { FlowNode, type BrandFlowNode } from "../flow-node";
61
+
62
+ /* -------------------------------------------------------------------------- */
63
+ /* The shared conventions, written once. */
64
+ /* -------------------------------------------------------------------------- */
65
+
66
+ /** Convention 1 + 3: the card is the node box, and it paints the proxied focus ring. */
67
+ const nodeCardClassName = (selected: boolean | undefined, className?: string) =>
68
+ cn(
69
+ "rounded-lg border border-border bg-flow-node text-flow-node-foreground shadow-sm",
70
+ "transition-[box-shadow,border-color] duration-fast ease-standard",
71
+ selected && "ring-2 ring-ring",
72
+ "[[data-id]:focus-visible_&]:focus-ring-static",
73
+ className,
74
+ );
75
+
76
+ /** Convention 2: the anchor class goes last on every handle. */
77
+ const handleClassName = `!size-2 !border-2 !border-flow-edge !bg-flow-node ${FLOW_HANDLE_ANCHOR_CLASS}`;
78
+
79
+ const edgeTypes = { brand: FlowEdge };
80
+ /** Fit at reading size: a two-node story should not be blown up to React Flow’s max zoom. */
81
+ const fitViewOptions = { maxZoom: 1, padding: 0.2 };
82
+
83
+ function Stage({ height = 320, children }: { height?: number; children: ReactNode }) {
84
+ return <div style={{ height }}>{children}</div>;
85
+ }
86
+
87
+ /* -------------------------------------------------------------------------- */
88
+ /* 1. Sectioned card — header, body, footer. */
89
+ /* -------------------------------------------------------------------------- */
90
+
91
+ type SectionedNode = Node<
92
+ { title: string; kind: string; icon: ReactNode; lines: [string, string][]; footer: string },
93
+ "sectioned"
94
+ >;
95
+
96
+ function SectionedCardNode({ data, selected }: NodeProps<SectionedNode>) {
97
+ return (
98
+ <div className={nodeCardClassName(selected, "w-60")} data-slot="sectioned-node">
99
+ <Handle className={handleClassName} position={Position.Left} type="target" />
100
+ <Handle className={handleClassName} position={Position.Right} type="source" />
101
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2">
102
+ <span aria-hidden="true" className="text-muted-foreground [&_svg]:size-4">
103
+ {data.icon}
104
+ </span>
105
+ <div className="min-w-0 flex-1">
106
+ <div className="text-eyebrow text-muted-foreground">{data.kind}</div>
107
+ <div className="truncate text-body font-medium">{data.title}</div>
108
+ </div>
109
+ </div>
110
+ <dl className="flex flex-col gap-1 px-3 py-2 text-caption">
111
+ {data.lines.map(([term, value]) => (
112
+ <div className="flex justify-between gap-3" key={term}>
113
+ <dt className="text-muted-foreground">{term}</dt>
114
+ <dd className="truncate font-medium tabular-nums">{value}</dd>
115
+ </div>
116
+ ))}
117
+ </dl>
118
+ <div className="border-t border-border px-3 py-1.5 text-meta text-muted-foreground">
119
+ {data.footer}
120
+ </div>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ /* -------------------------------------------------------------------------- */
126
+ /* 2. Status node — an execution state with a second channel (glyph + word). */
127
+ /* -------------------------------------------------------------------------- */
128
+
129
+ type StepStatus = "pending" | "running" | "complete" | "failed";
130
+ type StatusNodeType = Node<{ title: string; detail: string; status: StepStatus }, "status">;
131
+
132
+ const statusBorder: Record<StepStatus, string> = {
133
+ pending: "border-border",
134
+ running: "border-info",
135
+ complete: "border-success",
136
+ failed: "border-destructive",
137
+ };
138
+
139
+ function StatusNode({ data, selected }: NodeProps<StatusNodeType>) {
140
+ return (
141
+ <div
142
+ className={nodeCardClassName(selected, cn("w-64 px-3 py-2", statusBorder[data.status]))}
143
+ data-slot="status-node"
144
+ data-status={data.status}
145
+ >
146
+ <Handle className={handleClassName} position={Position.Left} type="target" />
147
+ <Handle className={handleClassName} position={Position.Right} type="source" />
148
+ <div className="flex items-start justify-between gap-2">
149
+ <div className="min-w-0">
150
+ <div className="truncate text-body font-medium">{data.title}</div>
151
+ <div className="truncate text-caption text-muted-foreground">{data.detail}</div>
152
+ </div>
153
+ {/* StatusBadge pairs every tone with its own glyph, so the state survives greyscale. */}
154
+ <StatusBadge size="sm" status={data.status} />
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ /* -------------------------------------------------------------------------- */
161
+ /* 3. Labelled ports — one handle per row, each with a stable id. */
162
+ /* -------------------------------------------------------------------------- */
163
+
164
+ type PortsNodeType = Node<{ title: string; inputs: string[]; outputs: string[] }, "ports">;
165
+
166
+ function PortRow({ id, label, side }: { id: string; label: string; side: "in" | "out" }) {
167
+ return (
168
+ // `relative` makes THIS row the handle's positioning box, so the dot sits at
169
+ // the row's mid-line on the card's edge instead of the card's mid-line.
170
+ <li
171
+ className={cn("relative px-3 py-1 text-caption", side === "out" ? "text-end" : "text-start")}
172
+ >
173
+ <Handle
174
+ className={handleClassName}
175
+ id={id}
176
+ position={side === "in" ? Position.Left : Position.Right}
177
+ type={side === "in" ? "target" : "source"}
178
+ />
179
+ <span className="font-mono">{label}</span>
180
+ </li>
181
+ );
182
+ }
183
+
184
+ function PortsNode({ data, selected }: NodeProps<PortsNodeType>) {
185
+ return (
186
+ <div className={nodeCardClassName(selected, "w-56")} data-slot="ports-node">
187
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2">
188
+ <Braces aria-hidden="true" className="size-4 text-muted-foreground" />
189
+ <span className="truncate text-body font-medium">{data.title}</span>
190
+ </div>
191
+ <div className="grid grid-cols-2 py-1">
192
+ <ul aria-label="Inputs">
193
+ {data.inputs.map((port) => (
194
+ <PortRow id={`in:${port}`} key={port} label={port} side="in" />
195
+ ))}
196
+ </ul>
197
+ <ul aria-label="Outputs">
198
+ {data.outputs.map((port) => (
199
+ <PortRow id={`out:${port}`} key={port} label={port} side="out" />
200
+ ))}
201
+ </ul>
202
+ </div>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ /* -------------------------------------------------------------------------- */
208
+ /* 4. Node toolbar — actions that appear with the selection. */
209
+ /* -------------------------------------------------------------------------- */
210
+
211
+ type ToolbarNodeType = Node<{ title: string; subtitle: string }, "toolbar">;
212
+
213
+ function ToolbarNode({ id, data, selected }: NodeProps<ToolbarNodeType>) {
214
+ const { deleteElements, getNode, addNodes } = useReactFlow();
215
+ const duplicate = () => {
216
+ const self = getNode(id);
217
+ if (!self) return;
218
+ addNodes({
219
+ ...self,
220
+ id: `${id}-copy-${Date.now()}`,
221
+ position: { x: self.position.x + 32, y: self.position.y + 72 },
222
+ selected: false,
223
+ });
224
+ };
225
+ return (
226
+ <div className={nodeCardClassName(selected, "w-52 px-3 py-2")} data-slot="toolbar-node">
227
+ {/* Visible while the node is selected; React Flow portals it above the node. */}
228
+ <NodeToolbar
229
+ className="nodrag flex items-center gap-0.5 rounded-md bg-surface-elevated p-0.5 shadow-ring-sm"
230
+ position={Position.Top}
231
+ >
232
+ <IconButton icon={<Play aria-hidden="true" />} label="Run from here" size="icon-sm" />
233
+ <IconButton
234
+ icon={<Copy aria-hidden="true" />}
235
+ label="Duplicate"
236
+ onClick={duplicate}
237
+ size="icon-sm"
238
+ />
239
+ <IconButton
240
+ icon={<Trash2 aria-hidden="true" />}
241
+ label="Delete"
242
+ onClick={() => void deleteElements({ nodes: [{ id }] })}
243
+ size="icon-sm"
244
+ />
245
+ </NodeToolbar>
246
+ <Handle className={handleClassName} position={Position.Left} type="target" />
247
+ <Handle className={handleClassName} position={Position.Right} type="source" />
248
+ <div className="truncate text-body font-medium">{data.title}</div>
249
+ <div className="truncate text-caption text-muted-foreground">{data.subtitle}</div>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ /* -------------------------------------------------------------------------- */
255
+ /* 5. Editable fields — controls inside a node write back to its data. */
256
+ /* -------------------------------------------------------------------------- */
257
+
258
+ type FilterNodeType = Node<{ field: string; threshold: number; enabled: boolean }, "filter">;
259
+
260
+ function FilterNode({ id, data, selected }: NodeProps<FilterNodeType>) {
261
+ const { updateNodeData } = useReactFlow();
262
+ return (
263
+ <div className={nodeCardClassName(selected, "w-64")} data-slot="filter-node">
264
+ <Handle className={handleClassName} position={Position.Left} type="target" />
265
+ <Handle className={handleClassName} position={Position.Right} type="source" />
266
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2">
267
+ <Filter aria-hidden="true" className="size-4 text-muted-foreground" />
268
+ <span className="flex-1 truncate text-body font-medium">Filter rows</span>
269
+ <Switch
270
+ aria-label="Filter enabled"
271
+ checked={data.enabled}
272
+ className="nodrag"
273
+ onCheckedChange={(enabled) => updateNodeData(id, { enabled })}
274
+ />
275
+ </div>
276
+ {/* `nodrag` on the fields: typing and selecting text must not drag the node. */}
277
+ <div className="nodrag flex flex-col gap-2 px-3 py-2">
278
+ <div className="flex flex-col gap-1">
279
+ <Label className="text-meta text-muted-foreground" htmlFor={`${id}-field`}>
280
+ Column
281
+ </Label>
282
+ <Input
283
+ id={`${id}-field`}
284
+ onChange={(event) => updateNodeData(id, { field: event.target.value })}
285
+ value={data.field}
286
+ />
287
+ </div>
288
+ <div className="flex flex-col gap-1">
289
+ <Label className="text-meta text-muted-foreground" htmlFor={`${id}-threshold`}>
290
+ Keep rows above
291
+ </Label>
292
+ <Input
293
+ id={`${id}-threshold`}
294
+ inputMode="numeric"
295
+ onChange={(event) => updateNodeData(id, { threshold: Number(event.target.value) || 0 })}
296
+ type="number"
297
+ value={data.threshold}
298
+ />
299
+ </div>
300
+ </div>
301
+ <div className="border-t border-border px-3 py-1.5 font-mono text-meta text-muted-foreground">
302
+ {data.enabled ? `${data.field || "…"} > ${data.threshold}` : "passes every row"}
303
+ </div>
304
+ </div>
305
+ );
306
+ }
307
+
308
+ /* -------------------------------------------------------------------------- */
309
+ /* 6. Annotation — a note that is not part of the graph. */
310
+ /* -------------------------------------------------------------------------- */
311
+
312
+ type NoteNodeType = Node<{ text: string }, "note">;
313
+
314
+ function NoteNode({ data, selected }: NodeProps<NoteNodeType>) {
315
+ return (
316
+ // No handles: a note cannot be wired. It still takes selection and focus.
317
+ <div
318
+ className={cn(
319
+ "flex w-56 gap-2 rounded-md border border-dashed border-border-strong bg-surface-muted px-3 py-2",
320
+ "text-caption text-muted-foreground",
321
+ selected && "ring-2 ring-ring",
322
+ "[[data-id]:focus-visible_&]:focus-ring-static",
323
+ )}
324
+ data-slot="note-node"
325
+ >
326
+ <StickyNote aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
327
+ <p>{data.text}</p>
328
+ </div>
329
+ );
330
+ }
331
+
332
+ /* -------------------------------------------------------------------------- */
333
+ /* 7. Resizable — the node takes whatever box the reader drags it to. */
334
+ /* -------------------------------------------------------------------------- */
335
+
336
+ type ResizableNodeType = Node<{ title: string; body: string }, "resizable">;
337
+
338
+ function ResizableNode({ data, selected }: NodeProps<ResizableNodeType>) {
339
+ return (
340
+ <div
341
+ className={nodeCardClassName(selected, "flex h-full w-full flex-col overflow-hidden")}
342
+ data-slot="resizable-node"
343
+ >
344
+ <NodeResizer
345
+ handleClassName="!size-2 !rounded-sm !border-ring !bg-flow-node"
346
+ isVisible={selected}
347
+ lineClassName="!border-ring"
348
+ minHeight={96}
349
+ minWidth={176}
350
+ />
351
+ <Handle className={handleClassName} position={Position.Left} type="target" />
352
+ <Handle className={handleClassName} position={Position.Right} type="source" />
353
+ <div className="border-b border-border px-3 py-2 text-body font-medium">{data.title}</div>
354
+ <p className="min-h-0 flex-1 overflow-auto px-3 py-2 text-caption text-muted-foreground">
355
+ {data.body}
356
+ </p>
357
+ </div>
358
+ );
359
+ }
360
+
361
+ /* -------------------------------------------------------------------------- */
362
+ /* Stories */
363
+ /* -------------------------------------------------------------------------- */
364
+
365
+ const meta = {
366
+ title: "Flow/Custom Nodes",
367
+ tags: ["autodocs"],
368
+ parameters: {
369
+ layout: "fullscreen",
370
+ docs: {
371
+ description: {
372
+ component:
373
+ "How to write your own node type on the branded canvas, with nothing but today’s exports. Each story is one self-contained node component registered in `nodeTypes`. They all follow the three conventions `FlowNode` follows: the painted card is the node box and the handles sit inside it; every `<Handle>` carries `FLOW_HANDLE_ANCHOR_CLASS`; and the keyboard focus indicator is the proxied `[[data-id]:focus-visible_&]:focus-ring-static`, separate from the `selected` ring. Controls inside a node carry React Flow’s `nodrag` class. When `FlowNode` plus its `footer` slot is enough, prefer it — see the last story.",
374
+ },
375
+ },
376
+ },
377
+ } satisfies Meta;
378
+ export default meta;
379
+ type Story = StoryObj<typeof meta>;
380
+
381
+ /** A card with a header, a definition list and a footer — the base shape most custom nodes start from. */
382
+ export const SectionedCard: Story = {
383
+ render: () => {
384
+ const nodes: SectionedNode[] = [
385
+ {
386
+ id: "inbox",
387
+ type: "sectioned",
388
+ position: { x: 0, y: 0 },
389
+ data: {
390
+ kind: "Trigger",
391
+ title: "Shared AP inbox",
392
+ icon: <Mail />,
393
+ lines: [
394
+ ["Mailbox", "ap@acme.example"],
395
+ ["Polls every", "5 min"],
396
+ ["Last 24 h", "212 messages"],
397
+ ],
398
+ footer: "Attachments: PDF, XML",
399
+ },
400
+ },
401
+ {
402
+ id: "sheet",
403
+ type: "sectioned",
404
+ position: { x: 340, y: 0 },
405
+ data: {
406
+ kind: "Output",
407
+ title: "Accruals workbook",
408
+ icon: <FileSpreadsheet />,
409
+ lines: [
410
+ ["Sheet", "2026-Q3"],
411
+ ["Mode", "Append rows"],
412
+ ["Owner", "Finance ops"],
413
+ ],
414
+ footer: "Writes 1 row per invoice",
415
+ },
416
+ },
417
+ ];
418
+ const edges: Edge[] = [{ id: "e", source: "inbox", target: "sheet", type: "brand" }];
419
+ return (
420
+ <Stage>
421
+ <CanvasShell
422
+ fitViewOptions={fitViewOptions}
423
+ edgeTypes={edgeTypes}
424
+ edges={edges}
425
+ nodeTypes={{ sectioned: SectionedCardNode }}
426
+ nodes={nodes}
427
+ />
428
+ </Stage>
429
+ );
430
+ },
431
+ };
432
+
433
+ /** One node per execution state. The border tints, and `StatusBadge` repeats the state as a glyph and a word. */
434
+ export const StatusStates: Story = {
435
+ render: () => {
436
+ const steps: [string, string, StepStatus][] = [
437
+ ["Fetch orders", "1,204 rows · 0.8 s", "complete"],
438
+ ["Match payments", "Row 640 of 1,204", "running"],
439
+ ["Post to ledger", "Waiting for match", "pending"],
440
+ ["Notify treasury", "SMTP timeout", "failed"],
441
+ ];
442
+ const nodes: StatusNodeType[] = steps.map(([title, detail, status], index) => ({
443
+ id: `s${index}`,
444
+ type: "status",
445
+ position: { x: index * 310, y: index % 2 === 0 ? 0 : 70 },
446
+ data: { title, detail, status },
447
+ }));
448
+ const edges: Edge[] = steps.slice(1).map((_, index) => ({
449
+ id: `e${index}`,
450
+ source: `s${index}`,
451
+ target: `s${index + 1}`,
452
+ type: "brand",
453
+ }));
454
+ return (
455
+ <Stage height={280}>
456
+ <CanvasShell
457
+ fitViewOptions={fitViewOptions}
458
+ edgeTypes={edgeTypes}
459
+ edges={edges}
460
+ nodeTypes={{ status: StatusNode }}
461
+ nodes={nodes}
462
+ />
463
+ </Stage>
464
+ );
465
+ },
466
+ };
467
+
468
+ /** A handle per row, each with its own id, so an edge names the exact port it uses (`sourceHandle` / `targetHandle`). */
469
+ export const LabelledPorts: Story = {
470
+ render: () => {
471
+ const nodes: PortsNodeType[] = [
472
+ {
473
+ id: "parse",
474
+ type: "ports",
475
+ position: { x: 0, y: 20 },
476
+ data: { title: "Parse invoice", inputs: ["document"], outputs: ["header", "lines", "tax"] },
477
+ },
478
+ {
479
+ id: "match",
480
+ type: "ports",
481
+ position: { x: 340, y: 0 },
482
+ data: {
483
+ title: "Three-way match",
484
+ inputs: ["header", "lines", "purchase_order"],
485
+ outputs: ["matched", "exceptions"],
486
+ },
487
+ },
488
+ ];
489
+ const edges: Edge[] = [
490
+ {
491
+ id: "e-header",
492
+ source: "parse",
493
+ sourceHandle: "out:header",
494
+ target: "match",
495
+ targetHandle: "in:header",
496
+ type: "brand",
497
+ },
498
+ {
499
+ id: "e-lines",
500
+ source: "parse",
501
+ sourceHandle: "out:lines",
502
+ target: "match",
503
+ targetHandle: "in:lines",
504
+ type: "brand",
505
+ },
506
+ ];
507
+ return (
508
+ <Stage>
509
+ <CanvasShell
510
+ fitViewOptions={fitViewOptions}
511
+ edgeTypes={edgeTypes}
512
+ edges={edges}
513
+ nodeTypes={{ ports: PortsNode }}
514
+ nodes={nodes}
515
+ />
516
+ </Stage>
517
+ );
518
+ },
519
+ };
520
+
521
+ /** Select a node (click, or Tab then Enter) and its actions appear above it. Duplicate and Delete are wired to the flow. */
522
+ export const WithToolbar: Story = {
523
+ render: () => {
524
+ const nodes: ToolbarNodeType[] = [
525
+ {
526
+ id: "hook",
527
+ type: "toolbar",
528
+ position: { x: 0, y: 60 },
529
+ selected: true,
530
+ data: { title: "Order webhook", subtitle: "POST /hooks/orders" },
531
+ },
532
+ {
533
+ id: "enrich",
534
+ type: "toolbar",
535
+ position: { x: 300, y: 60 },
536
+ data: { title: "Enrich customer", subtitle: "CRM lookup by email" },
537
+ },
538
+ ];
539
+ const edges: Edge[] = [{ id: "e", source: "hook", target: "enrich", type: "brand" }];
540
+ return (
541
+ <Stage>
542
+ <CanvasShell
543
+ fitViewOptions={fitViewOptions}
544
+ defaultEdges={edges}
545
+ defaultNodes={nodes}
546
+ edgeTypes={edgeTypes}
547
+ nodeTypes={{ toolbar: ToolbarNode }}
548
+ />
549
+ </Stage>
550
+ );
551
+ },
552
+ };
553
+
554
+ /** Inputs and a switch inside the node write to its `data` through `updateNodeData`; the footer reads it back. */
555
+ export const EditableFields: Story = {
556
+ render: () => {
557
+ const nodes: (FilterNodeType | BrandFlowNode)[] = [
558
+ {
559
+ id: "source",
560
+ type: "brand",
561
+ position: { x: 0, y: 70 },
562
+ data: { kind: "Source", title: "Open invoices", icon: <Webhook /> },
563
+ sourcePosition: Position.Right,
564
+ targetPosition: Position.Left,
565
+ },
566
+ {
567
+ id: "filter",
568
+ type: "filter",
569
+ position: { x: 280, y: 0 },
570
+ data: { field: "amount_eur", threshold: 5000, enabled: true },
571
+ },
572
+ ];
573
+ const edges: Edge[] = [{ id: "e", source: "source", target: "filter", type: "brand" }];
574
+ return (
575
+ <Stage height={340}>
576
+ <CanvasShell
577
+ fitViewOptions={fitViewOptions}
578
+ defaultEdges={edges}
579
+ defaultNodes={nodes}
580
+ edgeTypes={edgeTypes}
581
+ nodeTypes={{ brand: FlowNode, filter: FilterNode }}
582
+ />
583
+ </Stage>
584
+ );
585
+ },
586
+ };
587
+
588
+ /** A note has no handles, so it cannot be wired into the graph — it explains the graph. */
589
+ export const Annotation: Story = {
590
+ render: () => {
591
+ const nodes: (NoteNodeType | BrandFlowNode)[] = [
592
+ {
593
+ id: "approve",
594
+ type: "brand",
595
+ position: { x: 0, y: 0 },
596
+ data: { kind: "Approval", title: "Controller sign-off", tone: "warning" },
597
+ },
598
+ {
599
+ id: "note",
600
+ type: "note",
601
+ position: { x: 230, y: -6 },
602
+ data: {
603
+ text: "Required above €5,000 since the 2026 audit. Do not remove without Finance.",
604
+ },
605
+ },
606
+ ];
607
+ return (
608
+ <Stage height={240}>
609
+ <CanvasShell
610
+ fitViewOptions={fitViewOptions}
611
+ edges={[]}
612
+ nodeTypes={{ brand: FlowNode, note: NoteNode }}
613
+ nodes={nodes}
614
+ />
615
+ </Stage>
616
+ );
617
+ },
618
+ };
619
+
620
+ /** Select the node and drag a corner. The card fills the box React Flow gives it (`h-full w-full`). */
621
+ export const Resizable: Story = {
622
+ render: () => {
623
+ const nodes: ResizableNodeType[] = [
624
+ {
625
+ id: "prompt",
626
+ type: "resizable",
627
+ position: { x: 0, y: 0 },
628
+ width: 280,
629
+ height: 140,
630
+ selected: true,
631
+ data: {
632
+ title: "Reply guidelines",
633
+ body: "Answer in the customer’s language. Quote the order number. Never promise a delivery date the carrier has not confirmed.",
634
+ },
635
+ },
636
+ ];
637
+ return (
638
+ <Stage>
639
+ <CanvasShell
640
+ fitViewOptions={fitViewOptions}
641
+ defaultEdges={[]}
642
+ defaultNodes={nodes}
643
+ nodeTypes={{ resizable: ResizableNode }}
644
+ />
645
+ </Stage>
646
+ );
647
+ },
648
+ };
649
+
650
+ /**
651
+ * Before writing a node at all: `FlowNode` takes a `footer` — a meter, a chip row, a button —
652
+ * and keeps the handles, tones and focus ring for free.
653
+ */
654
+ export const FooterSlotFirst: Story = {
655
+ render: () => {
656
+ const nodes: BrandFlowNode[] = [
657
+ {
658
+ id: "queue",
659
+ type: "brand",
660
+ position: { x: 0, y: 0 },
661
+ data: {
662
+ kind: "Queue",
663
+ title: "Exceptions",
664
+ subtitle: "38 of 50 slots used",
665
+ tone: "warning",
666
+ footer: (
667
+ <Meter
668
+ aria-label="Exceptions queue: 38 of 50 slots used"
669
+ max={50}
670
+ size="xs"
671
+ value={38}
672
+ />
673
+ ),
674
+ },
675
+ },
676
+ {
677
+ id: "tags",
678
+ type: "brand",
679
+ position: { x: 260, y: 0 },
680
+ data: {
681
+ kind: "Classifier",
682
+ title: "Route by topic",
683
+ footer: (
684
+ <div className="flex flex-wrap gap-1">
685
+ <Badge variant="secondary">billing</Badge>
686
+ <Badge variant="secondary">delivery</Badge>
687
+ <Badge variant="secondary">returns</Badge>
688
+ </div>
689
+ ),
690
+ },
691
+ },
692
+ {
693
+ id: "manual",
694
+ type: "brand",
695
+ position: { x: 520, y: 0 },
696
+ data: {
697
+ kind: "Manual step",
698
+ title: "Review sample",
699
+ footer: (
700
+ <Button className="nodrag w-full" size="sm" variant="secondary">
701
+ Open 12 items
702
+ </Button>
703
+ ),
704
+ },
705
+ },
706
+ ];
707
+ return (
708
+ <Stage height={260}>
709
+ <CanvasShell
710
+ fitViewOptions={fitViewOptions}
711
+ edges={[]}
712
+ nodeTypes={{ brand: FlowNode }}
713
+ nodes={nodes}
714
+ />
715
+ </Stage>
716
+ );
717
+ },
718
+ };