@elabs-ai/components-flow 4.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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/index.d.ts +634 -0
  4. package/dist/index.js +1577 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +68 -0
  7. package/src/canvas-shell/canvas-shell.stories.tsx +77 -0
  8. package/src/canvas-shell/canvas-shell.test.tsx +106 -0
  9. package/src/canvas-shell/canvas-shell.tsx +128 -0
  10. package/src/canvas-shell/index.ts +1 -0
  11. package/src/flow-button-edge/flow-button-edge.stories.tsx +151 -0
  12. package/src/flow-button-edge/flow-button-edge.test.tsx +99 -0
  13. package/src/flow-button-edge/flow-button-edge.tsx +80 -0
  14. package/src/flow-button-edge/index.ts +5 -0
  15. package/src/flow-edge/flow-edge.stories.tsx +79 -0
  16. package/src/flow-edge/flow-edge.test.tsx +76 -0
  17. package/src/flow-edge/flow-edge.tsx +34 -0
  18. package/src/flow-edge/index.ts +1 -0
  19. package/src/flow-floating-edge/floating-edge-geometry.test.ts +100 -0
  20. package/src/flow-floating-edge/floating-edge-geometry.ts +128 -0
  21. package/src/flow-floating-edge/flow-floating-edge.stories.tsx +81 -0
  22. package/src/flow-floating-edge/flow-floating-edge.tsx +82 -0
  23. package/src/flow-floating-edge/index.ts +6 -0
  24. package/src/flow-group-node/flow-group-node.stories.tsx +283 -0
  25. package/src/flow-group-node/flow-group-node.test.tsx +100 -0
  26. package/src/flow-group-node/flow-group-node.tsx +134 -0
  27. package/src/flow-group-node/index.ts +6 -0
  28. package/src/flow-layout/flow-layout.stories.tsx +215 -0
  29. package/src/flow-layout/flow-layout.test.tsx +133 -0
  30. package/src/flow-layout/flow-layout.ts +104 -0
  31. package/src/flow-layout/index.ts +14 -0
  32. package/src/flow-layout/layout-graph.test.ts +257 -0
  33. package/src/flow-layout/layout-graph.ts +302 -0
  34. package/src/flow-layout/use-auto-layout.ts +26 -0
  35. package/src/flow-layout/use-flow-layout.ts +60 -0
  36. package/src/flow-mini-map/flow-mini-map.stories.tsx +74 -0
  37. package/src/flow-mini-map/flow-mini-map.test.tsx +56 -0
  38. package/src/flow-mini-map/flow-mini-map.tsx +29 -0
  39. package/src/flow-mini-map/index.ts +1 -0
  40. package/src/flow-node/flow-node.stories.tsx +143 -0
  41. package/src/flow-node/flow-node.test.tsx +156 -0
  42. package/src/flow-node/flow-node.tsx +190 -0
  43. package/src/flow-node/index.ts +8 -0
  44. package/src/flow-placeholder-node/flow-placeholder-node.stories.tsx +260 -0
  45. package/src/flow-placeholder-node/flow-placeholder-node.test.tsx +101 -0
  46. package/src/flow-placeholder-node/flow-placeholder-node.tsx +49 -0
  47. package/src/flow-placeholder-node/index.ts +5 -0
  48. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +75 -0
  49. package/src/flow-smart-edge/flow-smart-edge.tsx +75 -0
  50. package/src/flow-smart-edge/index.ts +9 -0
  51. package/src/flow-smart-edge/smart-edge-geometry.test.ts +120 -0
  52. package/src/flow-smart-edge/smart-edge-geometry.ts +116 -0
  53. package/src/helper-lines/get-helper-lines.ts +98 -0
  54. package/src/helper-lines/helper-lines.stories.tsx +107 -0
  55. package/src/helper-lines/helper-lines.test.tsx +78 -0
  56. package/src/helper-lines/helper-lines.tsx +67 -0
  57. package/src/helper-lines/index.ts +7 -0
  58. package/src/helper-lines/use-helper-lines.ts +109 -0
  59. package/src/index.ts +37 -0
  60. package/src/inspector-panel/index.ts +1 -0
  61. package/src/inspector-panel/inspector-panel.stories.tsx +161 -0
  62. package/src/inspector-panel/inspector-panel.test.tsx +82 -0
  63. package/src/inspector-panel/inspector-panel.tsx +129 -0
  64. package/src/legend/index.ts +1 -0
  65. package/src/legend/legend.stories.tsx +56 -0
  66. package/src/legend/legend.test.tsx +46 -0
  67. package/src/legend/legend.tsx +39 -0
  68. package/src/templates-flow-workspace.stories.tsx +169 -0
  69. package/src/use-flow-groups/group-operations.test.ts +318 -0
  70. package/src/use-flow-groups/group-operations.ts +349 -0
  71. package/src/use-flow-groups/index.ts +13 -0
  72. package/src/use-flow-groups/use-flow-groups.ts +113 -0
  73. package/src/zoom-controls/index.ts +1 -0
  74. package/src/zoom-controls/zoom-controls.stories.tsx +68 -0
  75. package/src/zoom-controls/zoom-controls.test.tsx +80 -0
  76. package/src/zoom-controls/zoom-controls.tsx +78 -0
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ // getEdgeParams imports the `Position` enum (a value) from @xyflow/react. The
4
+ // real package needs browser layout; mock it down to the Position constants so
5
+ // the PURE geometry can be exercised in jsdom.
6
+ vi.mock("@xyflow/react", () => ({
7
+ Position: { Left: "left", Top: "top", Right: "right", Bottom: "bottom" },
8
+ }));
9
+
10
+ import { getEdgeParams, type FloatingNodeGeometry } from "./floating-edge-geometry";
11
+
12
+ /** 100×60 node whose top-left sits at (x, y). */
13
+ function node(x: number, y: number, width = 100, height = 60): FloatingNodeGeometry {
14
+ return { internals: { positionAbsolute: { x, y } }, measured: { width, height } };
15
+ }
16
+
17
+ describe("getEdgeParams (floating edge border intersection)", () => {
18
+ it("target directly to the right → source exits right, target enters left", () => {
19
+ const source = node(0, 0);
20
+ const target = node(300, 0);
21
+ const p = getEdgeParams(source, target);
22
+
23
+ expect(p.sourcePos).toBe("right");
24
+ expect(p.targetPos).toBe("left");
25
+ // Source border point is on the right edge (x = 100), target on left (x = 300).
26
+ expect(p.sx).toBeCloseTo(100);
27
+ expect(p.tx).toBeCloseTo(300);
28
+ // Both attach at the vertical center (y = 30).
29
+ expect(p.sy).toBeCloseTo(30);
30
+ expect(p.ty).toBeCloseTo(30);
31
+ });
32
+
33
+ it("target directly to the left → source exits left, target enters right", () => {
34
+ const p = getEdgeParams(node(300, 0), node(0, 0));
35
+ expect(p.sourcePos).toBe("left");
36
+ expect(p.targetPos).toBe("right");
37
+ expect(p.sx).toBeCloseTo(300);
38
+ expect(p.tx).toBeCloseTo(100);
39
+ });
40
+
41
+ it("target directly below → source exits bottom, target enters top", () => {
42
+ const source = node(0, 0);
43
+ const target = node(0, 300);
44
+ const p = getEdgeParams(source, target);
45
+
46
+ expect(p.sourcePos).toBe("bottom");
47
+ expect(p.targetPos).toBe("top");
48
+ // Source bottom edge (y = 60), target top edge (y = 300).
49
+ expect(p.sy).toBeCloseTo(60);
50
+ expect(p.ty).toBeCloseTo(300);
51
+ // Horizontal center (x = 50).
52
+ expect(p.sx).toBeCloseTo(50);
53
+ expect(p.tx).toBeCloseTo(50);
54
+ });
55
+
56
+ it("target directly above → source exits top, target enters bottom", () => {
57
+ const p = getEdgeParams(node(0, 300), node(0, 0));
58
+ expect(p.sourcePos).toBe("top");
59
+ expect(p.targetPos).toBe("bottom");
60
+ expect(p.sy).toBeCloseTo(300);
61
+ expect(p.ty).toBeCloseTo(60);
62
+ });
63
+
64
+ it("diagonal placement → the point lands EXACTLY on a border (not inside)", () => {
65
+ const source = node(0, 0);
66
+ const target = node(300, 300);
67
+ const p = getEdgeParams(source, target);
68
+
69
+ // Facing down-right, this 100×60 node exits its BOTTOM edge (y = 60) at x = 80.
70
+ expect(p.sourcePos).toBe("bottom");
71
+ expect(p.sy).toBeCloseTo(60); // exactly on the border, never a hair inside
72
+ expect(p.sx).toBeCloseTo(80);
73
+ // The target (up-left of its own centre) enters its TOP edge (y = 300).
74
+ expect(p.targetPos).toBe("top");
75
+ expect(p.ty).toBeCloseTo(300);
76
+ expect(p.tx).toBeCloseTo(320);
77
+ });
78
+
79
+ it("wide node (story aspect ratio) → the near-horizontal edge exits the right side on-border", () => {
80
+ // ~176×80 card (min-w-44) with a target up and to the right.
81
+ const source = node(0, 0, 180, 80);
82
+ const target = node(500, -200, 180, 80);
83
+ const p = getEdgeParams(source, target);
84
+
85
+ expect(p.sourcePos).toBe("right");
86
+ expect(p.sx).toBeCloseTo(180); // exactly the right border, not inside the card
87
+ expect(p.sy).toBeGreaterThanOrEqual(0);
88
+ expect(p.sy).toBeLessThanOrEqual(80);
89
+ });
90
+
91
+ it("an unmeasured node does not throw and yields finite coordinates", () => {
92
+ const source: FloatingNodeGeometry = {
93
+ internals: { positionAbsolute: { x: 0, y: 0 } },
94
+ measured: {},
95
+ };
96
+ const p = getEdgeParams(source, node(300, 0));
97
+ expect(Number.isFinite(p.sx)).toBe(true);
98
+ expect(Number.isFinite(p.sy)).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,128 @@
1
+ import { Position } from "@xyflow/react";
2
+
3
+ /**
4
+ * The minimal live node geometry `getEdgeParams` needs. A React Flow
5
+ * `InternalNode` satisfies this structurally (`internals.positionAbsolute` +
6
+ * `measured`), so you can pass one directly; tests can pass a plain fake.
7
+ */
8
+ export interface FloatingNodeGeometry {
9
+ internals: { positionAbsolute: { x: number; y: number } };
10
+ measured: { width?: number; height?: number };
11
+ }
12
+
13
+ interface Point {
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ /**
19
+ * The point where the line from `intersectionNode`'s center to `targetNode`'s
20
+ * center crosses `intersectionNode`'s rectangle border. Standard React Flow
21
+ * floating-edge math (ellipse-normalized).
22
+ */
23
+ function getNodeIntersection(
24
+ intersectionNode: FloatingNodeGeometry,
25
+ targetNode: FloatingNodeGeometry,
26
+ ): Point {
27
+ const w = (intersectionNode.measured.width ?? 0) / 2;
28
+ const h = (intersectionNode.measured.height ?? 0) / 2;
29
+ const pos = intersectionNode.internals.positionAbsolute;
30
+ const targetPos = targetNode.internals.positionAbsolute;
31
+
32
+ const x2 = pos.x + w;
33
+ const y2 = pos.y + h;
34
+ const x1 = targetPos.x + (targetNode.measured.width ?? 0) / 2;
35
+ const y1 = targetPos.y + (targetNode.measured.height ?? 0) / 2;
36
+
37
+ // A not-yet-measured node has w/h = 0; guard the divisions so we return the
38
+ // node's position (finite) instead of NaN (which would break the edge path and
39
+ // make the line appear to end at a random spot until measurement lands).
40
+ const safeW = w || 1;
41
+ const safeH = h || 1;
42
+
43
+ const xx1 = (x1 - x2) / (2 * safeW) - (y1 - y2) / (2 * safeH);
44
+ const yy1 = (x1 - x2) / (2 * safeW) + (y1 - y2) / (2 * safeH);
45
+ const a = 1 / (Math.abs(xx1) + Math.abs(yy1) || 1);
46
+ const xx3 = a * xx1;
47
+ const yy3 = a * yy1;
48
+ const x = safeW * (xx3 + yy3) + x2;
49
+ const y = safeH * (-xx3 + yy3) + y2;
50
+
51
+ return { x, y };
52
+ }
53
+
54
+ /**
55
+ * Snap `point` exactly onto `node`'s nearest border side. The intersection math
56
+ * already lands on the border for a measured node; this guarantees it (removing
57
+ * any sub-pixel drift that would leave the anchor a hair inside the node) and
58
+ * gives a sensible on-border fallback if a point ever lands inside. Returns the
59
+ * point unchanged when the node has no measured size yet.
60
+ */
61
+ function clampToBorder(point: Point, node: FloatingNodeGeometry): Point {
62
+ const x0 = node.internals.positionAbsolute.x;
63
+ const y0 = node.internals.positionAbsolute.y;
64
+ const w = node.measured.width ?? 0;
65
+ const h = node.measured.height ?? 0;
66
+ if (w <= 0 || h <= 0) return point;
67
+
68
+ const cx = Math.min(Math.max(point.x, x0), x0 + w);
69
+ const cy = Math.min(Math.max(point.y, y0), y0 + h);
70
+ // Distance from the (clamped-inside) point to each side; snap to the nearest.
71
+ const dLeft = cx - x0;
72
+ const dRight = x0 + w - cx;
73
+ const dTop = cy - y0;
74
+ const dBottom = y0 + h - cy;
75
+ const nearest = Math.min(dLeft, dRight, dTop, dBottom);
76
+ if (nearest === dLeft) return { x: x0, y: cy };
77
+ if (nearest === dRight) return { x: x0 + w, y: cy };
78
+ if (nearest === dTop) return { x: cx, y: y0 };
79
+ return { x: cx, y: y0 + h };
80
+ }
81
+
82
+ /** Which border of `node` the intersection `point` sits on. */
83
+ function getEdgeBorderPosition(node: FloatingNodeGeometry, point: Point): Position {
84
+ const nx = Math.round(node.internals.positionAbsolute.x);
85
+ const ny = Math.round(node.internals.positionAbsolute.y);
86
+ const px = Math.round(point.x);
87
+ const py = Math.round(point.y);
88
+ const w = node.measured.width ?? 0;
89
+ const h = node.measured.height ?? 0;
90
+
91
+ if (px <= nx + 1) return Position.Left;
92
+ if (px >= nx + w - 1) return Position.Right;
93
+ if (py <= ny + 1) return Position.Top;
94
+ if (py >= ny + h - 1) return Position.Bottom;
95
+ return Position.Top;
96
+ }
97
+
98
+ /** Border anchor points + `Position`s for a floating edge between two nodes. */
99
+ export interface EdgeParams {
100
+ sx: number;
101
+ sy: number;
102
+ tx: number;
103
+ ty: number;
104
+ sourcePos: Position;
105
+ targetPos: Position;
106
+ }
107
+
108
+ /**
109
+ * Computes where a floating edge attaches to each node's border — the
110
+ * intersection of the center-to-center line with each rectangle — plus the
111
+ * border `Position`. Recompute per render so anchors track dragging nodes.
112
+ */
113
+ export function getEdgeParams(
114
+ source: FloatingNodeGeometry,
115
+ target: FloatingNodeGeometry,
116
+ ): EdgeParams {
117
+ const sourceIntersection = clampToBorder(getNodeIntersection(source, target), source);
118
+ const targetIntersection = clampToBorder(getNodeIntersection(target, source), target);
119
+
120
+ return {
121
+ sx: sourceIntersection.x,
122
+ sy: sourceIntersection.y,
123
+ tx: targetIntersection.x,
124
+ ty: targetIntersection.y,
125
+ sourcePos: getEdgeBorderPosition(source, sourceIntersection),
126
+ targetPos: getEdgeBorderPosition(target, targetIntersection),
127
+ };
128
+ }
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import "@xyflow/react/dist/style.css";
3
+ import { type Edge } from "@xyflow/react";
4
+ import { CanvasShell } from "../canvas-shell";
5
+ import { FlowNode, type BrandFlowNode } from "../flow-node";
6
+ import { FlowFloatingEdge } from "./flow-floating-edge";
7
+
8
+ const nodeTypes = { brand: FlowNode };
9
+ const edgeTypes = { floating: FlowFloatingEdge };
10
+
11
+ const meta = {
12
+ title: "Flow/FlowFloatingEdge",
13
+ component: FlowFloatingEdge,
14
+ tags: ["autodocs"],
15
+ parameters: { layout: "fullscreen" },
16
+ } satisfies Meta<typeof FlowFloatingEdge>;
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ const flowNodes: BrandFlowNode[] = [
21
+ {
22
+ id: "a",
23
+ type: "brand",
24
+ position: { x: 120, y: 160 },
25
+ data: { kind: "Source", title: "Ingest", tone: "accent" },
26
+ },
27
+ {
28
+ id: "b",
29
+ type: "brand",
30
+ position: { x: 460, y: 80 },
31
+ data: { kind: "Process", title: "Transform" },
32
+ },
33
+ {
34
+ id: "c",
35
+ type: "brand",
36
+ position: { x: 460, y: 300 },
37
+ data: { kind: "Output", title: "Publish", tone: "success" },
38
+ },
39
+ ];
40
+
41
+ /**
42
+ * Plain nodes (default handles) connected by `FlowFloatingEdge`. The edge attaches
43
+ * to the node border at the point facing the other node (no fixed handle), so it
44
+ * slides around the border as nodes are dragged. A small **anchor dot** marks each
45
+ * connection point on the node's closest side, so the line clearly terminates on
46
+ * the border (not at a bare, unanchored spot). Drag a node to watch the anchors
47
+ * and the edge slide around the border together.
48
+ */
49
+ export const Default: Story = {
50
+ render: () => {
51
+ const edges: Edge[] = [
52
+ { id: "a-b", source: "a", target: "b", type: "floating" },
53
+ { id: "a-c", source: "a", target: "c", type: "floating" },
54
+ { id: "b-c", source: "b", target: "c", type: "floating" },
55
+ ];
56
+ return (
57
+ <div className="h-[480px]">
58
+ <CanvasShell nodes={flowNodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
59
+ </div>
60
+ );
61
+ },
62
+ };
63
+
64
+ /**
65
+ * The anchor dot is on by default; hide it per edge with `data: { anchors: false }`
66
+ * for a plain hairline that still floats to the border.
67
+ */
68
+ export const AnchorsHidden: Story = {
69
+ render: () => {
70
+ const edges: Edge[] = [
71
+ { id: "a-b", source: "a", target: "b", type: "floating", data: { anchors: false } },
72
+ { id: "a-c", source: "a", target: "c", type: "floating", data: { anchors: false } },
73
+ { id: "b-c", source: "b", target: "c", type: "floating", data: { anchors: false } },
74
+ ];
75
+ return (
76
+ <div className="h-[480px]">
77
+ <CanvasShell nodes={flowNodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
78
+ </div>
79
+ );
80
+ },
81
+ };
@@ -0,0 +1,82 @@
1
+ import { BaseEdge, getBezierPath, useInternalNode, type EdgeProps } from "@xyflow/react";
2
+ import { getEdgeParams } from "./floating-edge-geometry";
3
+
4
+ /** Optional per-edge `data` for {@link FlowFloatingEdge}. */
5
+ export interface FloatingEdgeData {
6
+ /**
7
+ * Show a small anchor dot at each border connection point. On by default so the
8
+ * edge visibly terminates on the node's closest side (rather than a bare line
9
+ * touching the border with no anchor). Set `false` to hide.
10
+ */
11
+ anchors?: boolean;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ /** Radius of the connection anchor dot (matches the `FlowNode` handle size). */
16
+ const ANCHOR_RADIUS = 4;
17
+
18
+ /**
19
+ * Branded floating edge: it attaches to the node **border** at the point facing
20
+ * the other node (no fixed handle), recomputed as nodes drag. Register it in
21
+ * `edgeTypes={{ floating: FlowFloatingEdge }}`; the connected nodes need no
22
+ * handle config. Uses the `--flow-edge` token, matching `FlowEdge`.
23
+ *
24
+ * A small **anchor dot** is drawn at each connection point (on by default) so the
25
+ * line clearly terminates on the node's closest border side — not at a bare,
26
+ * unanchored spot. Toggle per edge with `data.anchors: false`.
27
+ */
28
+ export function FlowFloatingEdge({ id, source, target, markerEnd, style, data }: EdgeProps) {
29
+ const sourceNode = useInternalNode(source);
30
+ const targetNode = useInternalNode(target);
31
+ if (!sourceNode || !targetNode) return null;
32
+
33
+ const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(sourceNode, targetNode);
34
+
35
+ const [edgePath] = getBezierPath({
36
+ sourceX: sx,
37
+ sourceY: sy,
38
+ sourcePosition: sourcePos,
39
+ targetX: tx,
40
+ targetY: ty,
41
+ targetPosition: targetPos,
42
+ });
43
+
44
+ const showAnchors = (data as FloatingEdgeData | undefined)?.anchors !== false;
45
+
46
+ return (
47
+ <>
48
+ <BaseEdge
49
+ id={id}
50
+ path={edgePath}
51
+ markerEnd={markerEnd}
52
+ style={{ stroke: "var(--flow-edge)", strokeWidth: 1.5, ...style }}
53
+ />
54
+ {showAnchors ? (
55
+ <g
56
+ className="brand-floating-edge__anchors"
57
+ fill="var(--flow-node)"
58
+ stroke="var(--flow-edge)"
59
+ >
60
+ {/* Decorative rings matching the FlowNode handle look; never intercept
61
+ pointer events so nodes stay draggable through them. */}
62
+ <circle
63
+ cx={sx}
64
+ cy={sy}
65
+ r={ANCHOR_RADIUS}
66
+ strokeWidth={2}
67
+ style={{ pointerEvents: "none" }}
68
+ aria-hidden="true"
69
+ />
70
+ <circle
71
+ cx={tx}
72
+ cy={ty}
73
+ r={ANCHOR_RADIUS}
74
+ strokeWidth={2}
75
+ style={{ pointerEvents: "none" }}
76
+ aria-hidden="true"
77
+ />
78
+ </g>
79
+ ) : null}
80
+ </>
81
+ );
82
+ }
@@ -0,0 +1,6 @@
1
+ export { FlowFloatingEdge, type FloatingEdgeData } from "./flow-floating-edge";
2
+ export {
3
+ getEdgeParams,
4
+ type EdgeParams,
5
+ type FloatingNodeGeometry,
6
+ } from "./floating-edge-geometry";
@@ -0,0 +1,283 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import "@xyflow/react/dist/style.css";
3
+ import { Panel, useEdgesState, useNodesState, type Edge, type Node } from "@xyflow/react";
4
+ import { Boxes } from "lucide-react";
5
+ import { CanvasShell } from "../canvas-shell";
6
+ import { FlowNode } from "../flow-node";
7
+ import { ZoomControls } from "../zoom-controls";
8
+ import { useFlowGroups } from "../use-flow-groups";
9
+ import { FlowGroupNode } from "./flow-group-node";
10
+
11
+ const nodeTypes = { brand: FlowNode, group: FlowGroupNode };
12
+
13
+ const toolbarButton =
14
+ "rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
15
+
16
+ const meta = {
17
+ title: "Flow/FlowGroupNode",
18
+ component: FlowGroupNode,
19
+ tags: ["autodocs"],
20
+ parameters: { layout: "fullscreen" },
21
+ } satisfies Meta<typeof FlowGroupNode>;
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ /* --------------------------------------------------------------------------
26
+ * Group & ungroup — select nodes, then group / dissolve them from a toolbar.
27
+ * ------------------------------------------------------------------------ */
28
+
29
+ const looseNodes: Node[] = [
30
+ {
31
+ id: "extract",
32
+ type: "brand",
33
+ position: { x: 60, y: 60 },
34
+ data: { kind: "Source", title: "Extract" },
35
+ },
36
+ {
37
+ id: "clean",
38
+ type: "brand",
39
+ position: { x: 300, y: 60 },
40
+ data: { kind: "Transform", title: "Clean" },
41
+ },
42
+ {
43
+ id: "join",
44
+ type: "brand",
45
+ position: { x: 300, y: 200 },
46
+ data: { kind: "Transform", title: "Join" },
47
+ },
48
+ {
49
+ id: "load",
50
+ type: "brand",
51
+ position: { x: 560, y: 130 },
52
+ data: { kind: "Output", title: "Load" },
53
+ },
54
+ ];
55
+
56
+ const looseEdges: Edge[] = [
57
+ { id: "e1", source: "extract", target: "clean" },
58
+ { id: "e2", source: "clean", target: "join" },
59
+ { id: "e3", source: "join", target: "load" },
60
+ ];
61
+
62
+ function GroupToolbar() {
63
+ const { groupSelection, ungroup } = useFlowGroups();
64
+ return (
65
+ <div className="flex gap-2">
66
+ <button
67
+ type="button"
68
+ className={toolbarButton}
69
+ onClick={() => groupSelection({ title: "Transforms", tone: "accent" })}
70
+ >
71
+ Group selection
72
+ </button>
73
+ <button type="button" className={toolbarButton} onClick={() => ungroup("group-transforms")}>
74
+ Ungroup
75
+ </button>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ function GroupAndUngroupDemo() {
81
+ const [nodes, , onNodesChange] = useNodesState(looseNodes);
82
+ const [edges, , onEdgesChange] = useEdgesState(looseEdges);
83
+ return (
84
+ <div className="h-[520px]">
85
+ <CanvasShell
86
+ nodes={nodes}
87
+ edges={edges}
88
+ nodeTypes={nodeTypes}
89
+ onNodesChange={onNodesChange}
90
+ onEdgesChange={onEdgesChange}
91
+ >
92
+ <Panel position="top-left">
93
+ <GroupToolbar />
94
+ </Panel>
95
+ <ZoomControls />
96
+ </CanvasShell>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Shift-click to select the `Clean` + `Join` nodes, then **Group selection** wraps
103
+ * them in a `FlowGroupNode` parent. **Ungroup** dissolves the group with the id
104
+ * `group-transforms` (this demo generates a random id, so use the header toggle
105
+ * for collapse and the toolbar mainly to see grouping).
106
+ */
107
+ export const GroupAndUngroup: Story = {
108
+ render: () => <GroupAndUngroupDemo />,
109
+ };
110
+
111
+ /* --------------------------------------------------------------------------
112
+ * Expand & collapse — a pre-built group with edges crossing the boundary.
113
+ * ------------------------------------------------------------------------ */
114
+
115
+ function preGroupedGraph(): { nodes: Node[]; edges: Edge[] } {
116
+ const nodes: Node[] = [
117
+ {
118
+ id: "pipeline",
119
+ type: "group",
120
+ position: { x: 80, y: 80 },
121
+ width: 420,
122
+ height: 240,
123
+ data: { title: "ETL pipeline", tone: "accent", icon: <Boxes /> },
124
+ },
125
+ {
126
+ id: "extract",
127
+ type: "brand",
128
+ parentId: "pipeline",
129
+ extent: "parent",
130
+ position: { x: 24, y: 70 },
131
+ data: { kind: "Source", title: "Extract" },
132
+ },
133
+ {
134
+ id: "transform",
135
+ type: "brand",
136
+ parentId: "pipeline",
137
+ extent: "parent",
138
+ position: { x: 230, y: 130 },
139
+ data: { kind: "Transform", title: "Transform" },
140
+ },
141
+ {
142
+ id: "warehouse",
143
+ type: "brand",
144
+ position: { x: 640, y: 160 },
145
+ data: { kind: "Output", title: "Warehouse" },
146
+ },
147
+ ];
148
+ const edges: Edge[] = [
149
+ { id: "e-ex-tr", source: "extract", target: "transform" }, // fully inside
150
+ { id: "e-tr-wh", source: "transform", target: "warehouse" }, // crosses boundary
151
+ ];
152
+ return { nodes, edges };
153
+ }
154
+
155
+ function CollapseToolbar({ groupId }: { groupId: string }) {
156
+ const { collapseGroup, expandGroup } = useFlowGroups();
157
+ return (
158
+ <div className="flex gap-2">
159
+ <button type="button" className={toolbarButton} onClick={() => collapseGroup(groupId)}>
160
+ Collapse
161
+ </button>
162
+ <button type="button" className={toolbarButton} onClick={() => expandGroup(groupId)}>
163
+ Expand
164
+ </button>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function ExpandAndCollapseDemo() {
170
+ const initial = preGroupedGraph();
171
+ const [nodes, , onNodesChange] = useNodesState(initial.nodes);
172
+ const [edges, , onEdgesChange] = useEdgesState(initial.edges);
173
+ return (
174
+ <div className="h-[520px]">
175
+ <CanvasShell
176
+ nodes={nodes}
177
+ edges={edges}
178
+ nodeTypes={nodeTypes}
179
+ onNodesChange={onNodesChange}
180
+ onEdgesChange={onEdgesChange}
181
+ >
182
+ <Panel position="top-left">
183
+ <CollapseToolbar groupId="pipeline" />
184
+ </Panel>
185
+ <ZoomControls />
186
+ </CanvasShell>
187
+ </div>
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Collapse the group to an overview chip via the header chevron (or the toolbar).
193
+ * The `Transform → Warehouse` edge crosses the collapse boundary, so it is
194
+ * re-routed to a **proxy edge** from the group node to `Warehouse`; the
195
+ * fully-inside `Extract → Transform` edge is simply hidden. Expanding restores the
196
+ * exact original graph.
197
+ */
198
+ export const ExpandAndCollapse: Story = {
199
+ render: () => <ExpandAndCollapseDemo />,
200
+ };
201
+
202
+ /* --------------------------------------------------------------------------
203
+ * Nested groups — a subgroup inside a group, each independently collapsible.
204
+ * ------------------------------------------------------------------------ */
205
+
206
+ function nestedGraph(): { nodes: Node[]; edges: Edge[] } {
207
+ const nodes: Node[] = [
208
+ {
209
+ id: "outer",
210
+ type: "group",
211
+ position: { x: 60, y: 60 },
212
+ width: 520,
213
+ height: 320,
214
+ data: { title: "Region: EU", tone: "accent" },
215
+ },
216
+ {
217
+ id: "ingest",
218
+ type: "brand",
219
+ parentId: "outer",
220
+ extent: "parent",
221
+ position: { x: 24, y: 90 },
222
+ data: { kind: "Source", title: "Ingest" },
223
+ },
224
+ {
225
+ id: "inner",
226
+ type: "group",
227
+ parentId: "outer",
228
+ extent: "parent",
229
+ position: { x: 230, y: 70 },
230
+ width: 250,
231
+ height: 200,
232
+ data: { title: "Enrichment", tone: "success" },
233
+ },
234
+ {
235
+ id: "geo",
236
+ type: "brand",
237
+ parentId: "inner",
238
+ extent: "parent",
239
+ position: { x: 24, y: 90 },
240
+ data: { kind: "Transform", title: "Geocode" },
241
+ },
242
+ {
243
+ id: "sink",
244
+ type: "brand",
245
+ position: { x: 700, y: 180 },
246
+ data: { kind: "Output", title: "Sink" },
247
+ },
248
+ ];
249
+ const edges: Edge[] = [
250
+ { id: "e-in-geo", source: "ingest", target: "geo" }, // crosses inner boundary
251
+ { id: "e-geo-sink", source: "geo", target: "sink" }, // crosses both boundaries
252
+ ];
253
+ return { nodes, edges };
254
+ }
255
+
256
+ function NestedGroupsDemo() {
257
+ const initial = nestedGraph();
258
+ const [nodes, , onNodesChange] = useNodesState(initial.nodes);
259
+ const [edges, , onEdgesChange] = useEdgesState(initial.edges);
260
+ return (
261
+ <div className="h-[560px]">
262
+ <CanvasShell
263
+ nodes={nodes}
264
+ edges={edges}
265
+ nodeTypes={nodeTypes}
266
+ onNodesChange={onNodesChange}
267
+ onEdgesChange={onEdgesChange}
268
+ >
269
+ <ZoomControls />
270
+ </CanvasShell>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ /**
276
+ * A subgroup (`Enrichment`) nested inside an outer group (`Region: EU`). Collapse
277
+ * the inner group first, then the outer — each header toggle works independently,
278
+ * and expanding the outer keeps the inner collapsed (no double-unhide). Edges that
279
+ * cross either boundary re-route to proxy edges on the relevant group.
280
+ */
281
+ export const NestedGroups: Story = {
282
+ render: () => <NestedGroupsDemo />,
283
+ };