@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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const setNodes = vi.fn();
5
+ const setEdges = vi.fn();
6
+ const getNodes = vi.fn(() => [
7
+ { id: "test-group", type: "group", position: { x: 0, y: 0 }, data: { title: "Group" } },
8
+ ]);
9
+ const getEdges = vi.fn(() => [] as unknown[]);
10
+
11
+ // @xyflow/react needs real layout/measurement — mock the engine and assert the
12
+ // brand component's own output. Real rendering + a11y are covered by Storybook
13
+ // interaction tests. The pure grouping logic is covered by group-operations.test.ts.
14
+ vi.mock("@xyflow/react", () => {
15
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- a vi.mock factory is hoisted above imports; a lazy require avoids the TDZ a top-level import would hit
16
+ const React = require("react");
17
+ return {
18
+ Handle: ({ type }: { type: string }) =>
19
+ React.createElement("div", { "data-testid": `handle-${type}` }),
20
+ NodeResizer: () => React.createElement("div", { "data-testid": "node-resizer" }),
21
+ Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
22
+ useNodes: () => [] as unknown[],
23
+ useReactFlow: () => ({ getNodes, getEdges, setNodes, setEdges }),
24
+ getNodesBounds: () => ({ x: 0, y: 0, width: 0, height: 0 }),
25
+ };
26
+ });
27
+
28
+ import { FlowGroupNode, type BrandFlowGroupNode } from "./flow-group-node";
29
+ import type { NodeProps } from "@xyflow/react";
30
+
31
+ afterEach(() => {
32
+ cleanup();
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ function makeProps(
37
+ data: BrandFlowGroupNode["data"],
38
+ overrides: Partial<NodeProps<BrandFlowGroupNode>> = {},
39
+ ): NodeProps<BrandFlowGroupNode> {
40
+ return {
41
+ id: "test-group",
42
+ data,
43
+ selected: false,
44
+ dragging: false,
45
+ zIndex: 0,
46
+ isConnectable: true,
47
+ draggable: true,
48
+ deletable: true,
49
+ selectable: true,
50
+ positionAbsoluteX: 0,
51
+ positionAbsoluteY: 0,
52
+ width: 320,
53
+ height: 200,
54
+ type: "group",
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ describe("FlowGroupNode", () => {
60
+ it("renders the title and the child-count badge", () => {
61
+ render(<FlowGroupNode {...makeProps({ title: "Transforms", childCount: 3 })} />);
62
+ expect(screen.getByText("Transforms")).toBeInTheDocument();
63
+ expect(screen.getByText("3")).toBeInTheDocument();
64
+ });
65
+
66
+ it("exposes an accessible collapse toggle (aria-expanded true when expanded)", () => {
67
+ render(<FlowGroupNode {...makeProps({ title: "G" })} />);
68
+ const button = screen.getByRole("button", { name: /collapse group g/i });
69
+ expect(button).toHaveAttribute("aria-expanded", "true");
70
+ });
71
+
72
+ it("shows an expand affordance (aria-expanded false) when collapsed", () => {
73
+ render(<FlowGroupNode {...makeProps({ title: "G", collapsed: true })} />);
74
+ const button = screen.getByRole("button", { name: /expand group g/i });
75
+ expect(button).toHaveAttribute("aria-expanded", "false");
76
+ });
77
+
78
+ it("toggling calls into the store (setNodes/setEdges)", () => {
79
+ render(<FlowGroupNode {...makeProps({ title: "Group" })} />);
80
+ fireEvent.click(screen.getByRole("button", { name: /collapse group group/i }));
81
+ expect(setNodes).toHaveBeenCalledTimes(1);
82
+ expect(setEdges).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ it("renders a NodeResizer only when selected and expanded", () => {
86
+ const { rerender } = render(
87
+ <FlowGroupNode {...makeProps({ title: "G" }, { selected: true })} />,
88
+ );
89
+ expect(screen.getByTestId("node-resizer")).toBeInTheDocument();
90
+
91
+ rerender(<FlowGroupNode {...makeProps({ title: "G", collapsed: true }, { selected: true })} />);
92
+ expect(screen.queryByTestId("node-resizer")).not.toBeInTheDocument();
93
+ });
94
+
95
+ it("renders group handles so proxy edges can attach", () => {
96
+ render(<FlowGroupNode {...makeProps({ title: "G" })} />);
97
+ expect(screen.getByTestId("handle-target")).toBeInTheDocument();
98
+ expect(screen.getByTestId("handle-source")).toBeInTheDocument();
99
+ });
100
+ });
@@ -0,0 +1,134 @@
1
+ import { type ReactNode } from "react";
2
+ import { Handle, NodeResizer, Position, useNodes, type Node, type NodeProps } from "@xyflow/react";
3
+ import { ChevronDown, ChevronRight } from "lucide-react";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import { useFlowGroups } from "../use-flow-groups";
6
+
7
+ /** Visual accent for a group container. */
8
+ export type FlowGroupTone = "default" | "accent" | "success" | "warning" | "destructive";
9
+
10
+ export interface FlowGroupNodeData extends Record<string, unknown> {
11
+ /** Header label for the group. */
12
+ title: string;
13
+ /** Optional leading glyph (a Lucide icon element, etc.). */
14
+ icon?: ReactNode;
15
+ /**
16
+ * Whether the group is collapsed to an overview chip. Managed by
17
+ * `useFlowGroups().collapseGroup` / `expandGroup`; the header toggle flips it.
18
+ */
19
+ collapsed?: boolean;
20
+ /**
21
+ * Fallback count shown next to the title. When omitted, the node counts its
22
+ * live direct children from the store, so the badge stays accurate as nodes
23
+ * are added or removed.
24
+ */
25
+ childCount?: number;
26
+ /** Accent tone for the header rule + count badge. */
27
+ tone?: FlowGroupTone;
28
+ }
29
+
30
+ export type BrandFlowGroupNode = Node<FlowGroupNodeData, "group">;
31
+
32
+ const toneAccent: Record<FlowGroupTone, string> = {
33
+ default: "text-muted-foreground",
34
+ // #399 — the group LABEL is text on the canvas, so it takes the on-surface
35
+ // `-text` rung. (The status rows below stay on their fill rungs; those are a
36
+ // separate, still-open question, not part of #399's enumerated call sites.)
37
+ accent: "text-primary-text",
38
+ success: "text-success",
39
+ warning: "text-warning",
40
+ destructive: "text-destructive",
41
+ };
42
+
43
+ const handleClassName = "!size-2 !border-2 !border-flow-group-border !bg-flow-group";
44
+
45
+ /**
46
+ * Branded group container node. Register it as `nodeTypes={{ group: FlowGroupNode }}`
47
+ * and create nodes with `type: "group"` + typed `data: FlowGroupNodeData`. Children
48
+ * are re-parented onto it (`parentId`, `extent: "parent"`) — see `useFlowGroups`.
49
+ *
50
+ * The header carries the title, a live child-count badge and a collapse/expand
51
+ * toggle (a real `<button>` with `aria-expanded`) that drives
52
+ * `useFlowGroups().collapseGroup` / `expandGroup`. When selected (and expanded)
53
+ * a `<NodeResizer>` lets the group be resized.
54
+ *
55
+ * The surface uses the FL-01 tokens `--flow-group` (fill) + `--flow-group-border`
56
+ * (border). Under a theme whose `--flow-group` is near-transparent, the
57
+ * drawn border does the work.
58
+ */
59
+ export function FlowGroupNode({ id, data, selected }: NodeProps<BrandFlowGroupNode>) {
60
+ const { toggleCollapse } = useFlowGroups();
61
+ const nodes = useNodes();
62
+ const collapsed = data.collapsed ?? false;
63
+ const tone = data.tone ?? "default";
64
+
65
+ // Live count of direct children; fall back to the stored count before the
66
+ // store settles (or in a non-interactive preview).
67
+ const liveChildCount = nodes.filter((n) => n.parentId === id).length;
68
+ const childCount = liveChildCount || data.childCount || 0;
69
+
70
+ return (
71
+ <div
72
+ className={cn(
73
+ "flex h-full w-full flex-col rounded-lg border bg-flow-group/60 text-foreground",
74
+ "border-flow-group-border transition-[box-shadow,border-color] duration-fast ease-standard motion-reduce:transition-none",
75
+ collapsed ? "shadow-sm" : "shadow-none",
76
+ selected && "ring-2 ring-ring",
77
+ )}
78
+ >
79
+ {!collapsed && selected ? (
80
+ <NodeResizer
81
+ minWidth={160}
82
+ minHeight={96}
83
+ lineClassName="!border-flow-group-border"
84
+ handleClassName={handleClassName}
85
+ />
86
+ ) : null}
87
+
88
+ {/* Handles so proxy edges can attach to the group when collapsed. */}
89
+ <Handle type="target" position={Position.Top} className={handleClassName} />
90
+ <Handle type="source" position={Position.Bottom} className={handleClassName} />
91
+
92
+ <div
93
+ className={cn(
94
+ "flex items-center gap-2 px-3 py-2",
95
+ !collapsed && "border-b border-flow-group-border",
96
+ )}
97
+ >
98
+ <button
99
+ type="button"
100
+ onClick={() => toggleCollapse(id)}
101
+ aria-label={collapsed ? `Expand group ${data.title}` : `Collapse group ${data.title}`}
102
+ aria-expanded={!collapsed}
103
+ className={cn(
104
+ "-ml-1 grid size-5 shrink-0 place-items-center rounded",
105
+ "text-muted-foreground hover:bg-muted hover:text-foreground",
106
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
107
+ )}
108
+ >
109
+ {collapsed ? (
110
+ <ChevronRight aria-hidden="true" className="size-4" />
111
+ ) : (
112
+ <ChevronDown aria-hidden="true" className="size-4" />
113
+ )}
114
+ </button>
115
+
116
+ {data.icon ? (
117
+ <span className={cn("[&_svg]:size-4", toneAccent[tone])}>{data.icon}</span>
118
+ ) : null}
119
+
120
+ <span className="min-w-0 flex-1 truncate text-body font-medium">{data.title}</span>
121
+
122
+ <span
123
+ className={cn(
124
+ "shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-meta font-medium tabular-nums",
125
+ toneAccent[tone],
126
+ )}
127
+ aria-label={`${childCount} ${childCount === 1 ? "node" : "nodes"}`}
128
+ >
129
+ {childCount}
130
+ </span>
131
+ </div>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ FlowGroupNode,
3
+ type FlowGroupNodeData,
4
+ type FlowGroupTone,
5
+ type BrandFlowGroupNode,
6
+ } from "./flow-group-node";
@@ -0,0 +1,215 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import "@xyflow/react/dist/style.css";
4
+ import { Panel, useEdgesState, useNodesState, useReactFlow, type Edge } from "@xyflow/react";
5
+ import { CanvasShell } from "../canvas-shell";
6
+ import { FlowSmartEdge } from "../flow-smart-edge";
7
+ import { FlowNode, FLOW_ALL_SIDE_HANDLES, type BrandFlowNode } from "../flow-node";
8
+ import { ZoomControls } from "../zoom-controls";
9
+ import { useFlowLayout } from "./use-flow-layout";
10
+ import type { FlowLayoutDirection } from "./flow-layout";
11
+ import { layoutGraph, type LayoutAlgorithm } from "./layout-graph";
12
+
13
+ const nodeTypes = { brand: FlowNode };
14
+ // Smart edges + all-side anchors: each edge connects on the side facing the other
15
+ // node and RE-picks that side as the layout moves nodes — so anchors flip
16
+ // top/bottom ↔ left/right automatically for any layout (TB, LR, concentric, …).
17
+ const edgeTypes = { smart: FlowSmartEdge };
18
+
19
+ /** A deliberately messy diamond graph — overlapping, unordered positions. */
20
+ const messyNodes: BrandFlowNode[] = [
21
+ {
22
+ id: "ingest",
23
+ type: "brand",
24
+ position: { x: 40, y: 260 },
25
+ data: { kind: "Source", title: "Ingest", subtitle: "Raw data in", tone: "accent" },
26
+ },
27
+ {
28
+ id: "transform",
29
+ type: "brand",
30
+ position: { x: 380, y: 20 },
31
+ data: { kind: "Process", title: "Transform", subtitle: "Normalize & enrich" },
32
+ },
33
+ {
34
+ id: "validate",
35
+ type: "brand",
36
+ position: { x: 60, y: 40 },
37
+ data: { kind: "Process", title: "Validate", subtitle: "Quality checks" },
38
+ },
39
+ {
40
+ id: "enrich",
41
+ type: "brand",
42
+ position: { x: 420, y: 340 },
43
+ data: { kind: "Process", title: "Enrich", subtitle: "Join reference data" },
44
+ },
45
+ {
46
+ id: "publish",
47
+ type: "brand",
48
+ position: { x: 150, y: 420 },
49
+ data: { kind: "Output", title: "Publish", subtitle: "Downstream sink", tone: "success" },
50
+ },
51
+ ].map((n) => ({ ...n, data: { ...n.data, handles: FLOW_ALL_SIDE_HANDLES } }));
52
+
53
+ const messyEdges: Edge[] = [
54
+ { id: "e-ingest-transform", source: "ingest", target: "transform", type: "smart" },
55
+ { id: "e-ingest-validate", source: "ingest", target: "validate", type: "smart" },
56
+ { id: "e-transform-enrich", source: "transform", target: "enrich", type: "smart" },
57
+ { id: "e-validate-enrich", source: "validate", target: "enrich", type: "smart" },
58
+ { id: "e-enrich-publish", source: "enrich", target: "publish", type: "smart" },
59
+ ];
60
+
61
+ function AutoLayoutButton({ direction }: { direction: FlowLayoutDirection }) {
62
+ const { layout, layouting } = useFlowLayout();
63
+ return (
64
+ <button
65
+ type="button"
66
+ disabled={layouting}
67
+ onClick={() => layout(direction)}
68
+ className="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"
69
+ >
70
+ Auto layout ({direction === "TB" ? "top → bottom" : "left → right"})
71
+ </button>
72
+ );
73
+ }
74
+
75
+ function FlowLayoutDemo({ direction }: { direction: FlowLayoutDirection }) {
76
+ const [nodes, , onNodesChange] = useNodesState(messyNodes);
77
+ const [edges, , onEdgesChange] = useEdgesState(messyEdges);
78
+
79
+ return (
80
+ <div className="h-[500px]">
81
+ <CanvasShell
82
+ nodes={nodes}
83
+ edges={edges}
84
+ nodeTypes={nodeTypes}
85
+ edgeTypes={edgeTypes}
86
+ onNodesChange={onNodesChange}
87
+ onEdgesChange={onEdgesChange}
88
+ >
89
+ <Panel position="top-left">
90
+ <AutoLayoutButton direction={direction} />
91
+ </Panel>
92
+ <ZoomControls />
93
+ </CanvasShell>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ const ALGORITHM_LABEL: Record<LayoutAlgorithm, string> = {
99
+ concentric: "concentric",
100
+ force: "force",
101
+ "layered-lr": "layered (left → right)",
102
+ grid: "grid",
103
+ };
104
+
105
+ function AutoLayoutGraphButton({ algorithm }: { algorithm: LayoutAlgorithm }) {
106
+ const { getNodes, getEdges, setNodes, fitView } = useReactFlow();
107
+ const [layouting, setLayouting] = useState(false);
108
+
109
+ const handleClick = () => {
110
+ setLayouting(true);
111
+ const laidOut = layoutGraph(getNodes(), getEdges(), {
112
+ algorithm,
113
+ centerId: "ingest",
114
+ ringRadius: 160,
115
+ iterations: 250,
116
+ spacing: { x: 220, y: 140 },
117
+ });
118
+ setNodes(laidOut);
119
+ requestAnimationFrame(() => {
120
+ fitView();
121
+ setLayouting(false);
122
+ });
123
+ };
124
+
125
+ return (
126
+ <button
127
+ type="button"
128
+ disabled={layouting}
129
+ onClick={handleClick}
130
+ className="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"
131
+ >
132
+ Auto layout ({ALGORITHM_LABEL[algorithm]})
133
+ </button>
134
+ );
135
+ }
136
+
137
+ /** Same messy dataset as `FlowLayoutDemo`, laid out via the pure `layoutGraph` helper. */
138
+ function LayoutGraphDemo({ algorithm }: { algorithm: LayoutAlgorithm }) {
139
+ const [nodes, , onNodesChange] = useNodesState(messyNodes);
140
+ const [edges, , onEdgesChange] = useEdgesState(messyEdges);
141
+
142
+ return (
143
+ <div className="h-[500px]">
144
+ <CanvasShell
145
+ nodes={nodes}
146
+ edges={edges}
147
+ nodeTypes={nodeTypes}
148
+ edgeTypes={edgeTypes}
149
+ onNodesChange={onNodesChange}
150
+ onEdgesChange={onEdgesChange}
151
+ >
152
+ <Panel position="top-left">
153
+ <AutoLayoutGraphButton algorithm={algorithm} />
154
+ </Panel>
155
+ <ZoomControls />
156
+ </CanvasShell>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ const meta = {
162
+ title: "Flow/FlowLayout",
163
+ tags: ["autodocs"],
164
+ parameters: { layout: "fullscreen" },
165
+ } satisfies Meta;
166
+ export default meta;
167
+ type Story = StoryObj<typeof meta>;
168
+
169
+ /**
170
+ * A messy graph; click "Auto layout" to run `useFlowLayout` (dagre, top → bottom).
171
+ * Nodes carry all-side anchors and `FlowSmartEdge`, so once stacked vertically the
172
+ * edges connect **bottom → top** (the facing sides).
173
+ */
174
+ export const Default: Story = {
175
+ render: () => <FlowLayoutDemo direction="TB" />,
176
+ };
177
+
178
+ /**
179
+ * Same graph, laid out left → right. Because the edges are smart + the nodes have
180
+ * anchors on every side, the connections **re-pick the facing side automatically**
181
+ * — now the right (source) / left (target) sides — instead of routing through
182
+ * top/bottom.
183
+ */
184
+ export const LeftToRight: Story = {
185
+ render: () => <FlowLayoutDemo direction="LR" />,
186
+ };
187
+
188
+ /**
189
+ * `layoutGraph({ algorithm: "concentric" })` — the "ingest" node is the focal
190
+ * center; the rest of the graph radiates outward in BFS shells.
191
+ */
192
+ export const Concentric: Story = {
193
+ render: () => <LayoutGraphDemo algorithm="concentric" />,
194
+ };
195
+
196
+ /**
197
+ * `layoutGraph({ algorithm: "force" })` — a deterministic, seeded `d3-force`
198
+ * simulation spreads the nodes apart.
199
+ */
200
+ export const Force: Story = {
201
+ render: () => <LayoutGraphDemo algorithm="force" />,
202
+ };
203
+
204
+ /**
205
+ * `layoutGraph({ algorithm: "layered-lr" })` — delegates to the same dagre
206
+ * engine as `useFlowLayout`, exposed as a pure function.
207
+ */
208
+ export const LayeredLR: Story = {
209
+ render: () => <LayoutGraphDemo algorithm="layered-lr" />,
210
+ };
211
+
212
+ /** `layoutGraph({ algorithm: "grid" })` — a simple row-major grid. */
213
+ export const Grid: Story = {
214
+ render: () => <LayoutGraphDemo algorithm="grid" />,
215
+ };
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Edge, Node } from "@xyflow/react";
3
+ import { layoutFlow } from "./flow-layout";
4
+
5
+ function chainNodes(): Node[] {
6
+ return [
7
+ { id: "a", type: "brand", position: { x: 0, y: 0 }, data: { title: "A" } },
8
+ { id: "b", type: "brand", position: { x: 0, y: 0 }, data: { title: "B" } },
9
+ { id: "c", type: "brand", position: { x: 0, y: 0 }, data: { title: "C" } },
10
+ ];
11
+ }
12
+
13
+ function chainEdges(): Edge[] {
14
+ return [
15
+ { id: "e-ab", source: "a", target: "b" },
16
+ { id: "e-bc", source: "b", target: "c" },
17
+ ];
18
+ }
19
+
20
+ describe("layoutFlow", () => {
21
+ it("orders a 3-node chain top-to-bottom for direction=TB (default)", () => {
22
+ const { nodes } = layoutFlow(chainNodes(), chainEdges());
23
+ const [a, b, c] = nodes as [Node, Node, Node];
24
+
25
+ // Ranks increase down the y axis; x stays roughly aligned per rank.
26
+ expect(a.position.y).toBeLessThan(b.position.y);
27
+ expect(b.position.y).toBeLessThan(c.position.y);
28
+ });
29
+
30
+ it("orders a 3-node chain left-to-right for direction=LR", () => {
31
+ const { nodes } = layoutFlow(chainNodes(), chainEdges(), { direction: "LR" });
32
+ const [a, b, c] = nodes as [Node, Node, Node];
33
+
34
+ expect(a.position.x).toBeLessThan(b.position.x);
35
+ expect(b.position.x).toBeLessThan(c.position.x);
36
+ });
37
+
38
+ it("moves the handle sides with the direction (TB → bottom/top, LR → right/left)", () => {
39
+ const tb = layoutFlow(chainNodes(), chainEdges(), { direction: "TB" }).nodes[0]!;
40
+ expect(tb.sourcePosition).toBe("bottom");
41
+ expect(tb.targetPosition).toBe("top");
42
+
43
+ const lr = layoutFlow(chainNodes(), chainEdges(), { direction: "LR" }).nodes[0]!;
44
+ expect(lr.sourcePosition).toBe("right");
45
+ expect(lr.targetPosition).toBe("left");
46
+
47
+ const rl = layoutFlow(chainNodes(), chainEdges(), { direction: "RL" }).nodes[0]!;
48
+ expect(rl.sourcePosition).toBe("left");
49
+ expect(rl.targetPosition).toBe("right");
50
+ });
51
+
52
+ it("reverses rank order for direction=BT relative to TB", () => {
53
+ const { nodes: tb } = layoutFlow(chainNodes(), chainEdges(), { direction: "TB" });
54
+ const { nodes: bt } = layoutFlow(chainNodes(), chainEdges(), { direction: "BT" });
55
+ const [tbA, tbB] = tb as [Node, Node];
56
+ const [btA, btB] = bt as [Node, Node];
57
+
58
+ // TB: a above b above c. BT: a below b below c.
59
+ expect(tbA.position.y).toBeLessThan(tbB.position.y);
60
+ expect(btA.position.y).toBeGreaterThan(btB.position.y);
61
+ });
62
+
63
+ it("preserves node identity and data — only position changes", () => {
64
+ const original = chainNodes();
65
+ const { nodes } = layoutFlow(original, chainEdges());
66
+
67
+ nodes.forEach((node, i) => {
68
+ expect(node.id).toBe(original[i]!.id);
69
+ expect(node.data).toBe(original[i]!.data);
70
+ expect(node.type).toBe(original[i]!.type);
71
+ });
72
+ });
73
+
74
+ it("preserves edges unchanged (same reference, same array)", () => {
75
+ const edges = chainEdges();
76
+ const { edges: outEdges } = layoutFlow(chainNodes(), edges);
77
+ expect(outEdges).toBe(edges);
78
+ });
79
+
80
+ it("honors measured size over a stale width/height", () => {
81
+ const nodes: Node[] = [
82
+ {
83
+ id: "a",
84
+ position: { x: 0, y: 0 },
85
+ data: {},
86
+ width: 50,
87
+ height: 20,
88
+ measured: { width: 400, height: 200 },
89
+ },
90
+ {
91
+ id: "b",
92
+ position: { x: 0, y: 0 },
93
+ data: {},
94
+ width: 50,
95
+ height: 20,
96
+ measured: { width: 400, height: 200 },
97
+ },
98
+ ];
99
+ const edges: Edge[] = [{ id: "e-ab", source: "a", target: "b" }];
100
+
101
+ const { nodes: layouted } = layoutFlow(nodes, edges, { direction: "LR" });
102
+ const [a, b] = layouted;
103
+
104
+ // With a 400-wide measured size and default 72px ranksep, b must start at
105
+ // least a.x + measured.width + ranksep away — a value only reachable if
106
+ // the (much larger) measured size, not the stale width, drove the layout.
107
+ expect(b!.position.x - a!.position.x).toBeGreaterThanOrEqual(400 + 72 - 1);
108
+ });
109
+
110
+ it("falls back to a sensible default size when neither measured nor width/height are set", () => {
111
+ const nodes: Node[] = [
112
+ { id: "a", position: { x: 0, y: 0 }, data: {} },
113
+ { id: "b", position: { x: 0, y: 0 }, data: {} },
114
+ ];
115
+ const edges: Edge[] = [{ id: "e-ab", source: "a", target: "b" }];
116
+
117
+ expect(() => layoutFlow(nodes, edges)).not.toThrow();
118
+ const { nodes: layouted } = layoutFlow(nodes, edges);
119
+ expect(Number.isFinite(layouted[0]!.position.x)).toBe(true);
120
+ expect(Number.isFinite(layouted[0]!.position.y)).toBe(true);
121
+ });
122
+
123
+ it("respects custom nodeSpacing/rankSpacing", () => {
124
+ const nodes = chainNodes();
125
+ const edges = chainEdges();
126
+ const tight = layoutFlow(nodes, edges, { rankSpacing: 10 });
127
+ const wide = layoutFlow(nodes, edges, { rankSpacing: 500 });
128
+
129
+ const tightGap = tight.nodes[1]!.position.y - tight.nodes[0]!.position.y;
130
+ const wideGap = wide.nodes[1]!.position.y - wide.nodes[0]!.position.y;
131
+ expect(wideGap).toBeGreaterThan(tightGap);
132
+ });
133
+ });