@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,349 @@
1
+ import { getNodesBounds, type Edge, type Node } from "@xyflow/react";
2
+ import type { FlowGroupNodeData, FlowGroupTone } from "../flow-group-node";
3
+
4
+ /**
5
+ * Pure, framework-agnostic grouping operations over `nodes`/`edges` arrays.
6
+ *
7
+ * Every function is a **pure transform** — it never mutates its inputs and
8
+ * returns a fresh `{ nodes, edges }`. This is the testable core; `useFlowGroups`
9
+ * is a thin hook that wires these to a live canvas via `useReactFlow()`.
10
+ */
11
+
12
+ /** Node type used for the group container (matches `nodeTypes={{ group: FlowGroupNode }}`). */
13
+ export const FLOW_GROUP_NODE_TYPE = "group";
14
+
15
+ /** Padding (px) left around the child bounds when a group is created. */
16
+ const DEFAULT_GROUP_PADDING = 28;
17
+ /** Extra top space reserved for the group header so children don't sit under it. */
18
+ const GROUP_HEADER_OFFSET = 44;
19
+ /** Fixed size of the collapsed "overview chip". */
20
+ const OVERVIEW_WIDTH = 220;
21
+ const OVERVIEW_HEIGHT = 48;
22
+
23
+ export interface GroupNodesOptions {
24
+ /** Id for the new group node. Required so the operation stays deterministic/testable. */
25
+ groupId: string;
26
+ /** Header title for the group. @default "Group" */
27
+ title?: string;
28
+ /** Accent tone for the group header. */
29
+ tone?: FlowGroupTone;
30
+ /** Padding (px) around the child bounds. @default 28 */
31
+ padding?: number;
32
+ }
33
+
34
+ export interface FlowGroupOperationResult<
35
+ NodeType extends Node = Node,
36
+ EdgeType extends Edge = Edge,
37
+ > {
38
+ nodes: NodeType[];
39
+ edges: EdgeType[];
40
+ }
41
+
42
+ /**
43
+ * Snapshot stashed on a collapsed group's `data` so `expandGroup` can restore the
44
+ * exact pre-collapse graph (an exact inverse). Holds original node/edge object
45
+ * references — never mutated — so restoration is object-exact.
46
+ */
47
+ interface FlowGroupCollapsedState {
48
+ /** The group node exactly as it was before collapse (no collapsed state on it). */
49
+ groupSnapshot: Node;
50
+ /** Every descendant node exactly as it was before collapse. */
51
+ descendantSnapshots: Node[];
52
+ /** The boundary-crossing edges exactly as they were before rerouting. */
53
+ reroutedEdges: Edge[];
54
+ }
55
+
56
+ const COLLAPSED_STATE_KEY = "__flowGroupCollapsedState";
57
+
58
+ /** Marker + metadata written onto a proxy edge's `data`. */
59
+ export interface FlowGroupProxyEdgeData extends Record<string, unknown> {
60
+ __flowGroupProxy: true;
61
+ /** Id of the collapsed group this proxy edge stands in for. */
62
+ groupId: string;
63
+ }
64
+
65
+ /** True if `edge` is a proxy edge synthesized for a collapsed group. */
66
+ export function isFlowGroupProxyEdge(edge: Edge): boolean {
67
+ return Boolean((edge.data as Partial<FlowGroupProxyEdgeData> | undefined)?.__flowGroupProxy);
68
+ }
69
+
70
+ function proxyEdgeId(groupId: string, originalEdgeId: string): string {
71
+ return `flow-group-proxy__${groupId}__${originalEdgeId}`;
72
+ }
73
+
74
+ function readCollapsedState(node: Node): FlowGroupCollapsedState | undefined {
75
+ return (node.data as Record<string, unknown> | undefined)?.[COLLAPSED_STATE_KEY] as
76
+ | FlowGroupCollapsedState
77
+ | undefined;
78
+ }
79
+
80
+ /** All node ids whose parent chain reaches `groupId` (children, grandchildren, …). */
81
+ function collectDescendantIds(nodes: Node[], groupId: string): Set<string> {
82
+ const childrenByParent = new Map<string, Node[]>();
83
+ for (const n of nodes) {
84
+ if (n.parentId) {
85
+ const arr = childrenByParent.get(n.parentId);
86
+ if (arr) arr.push(n);
87
+ else childrenByParent.set(n.parentId, [n]);
88
+ }
89
+ }
90
+
91
+ const out = new Set<string>();
92
+ const stack = [...(childrenByParent.get(groupId) ?? [])];
93
+ while (stack.length > 0) {
94
+ const n = stack.pop()!;
95
+ if (out.has(n.id)) continue;
96
+ out.add(n.id);
97
+ const kids = childrenByParent.get(n.id);
98
+ if (kids) stack.push(...kids);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Wrap `nodeIds` (assumed top-level) in a new `FlowGroupNode` parent.
105
+ *
106
+ * - Computes the child bounds with `getNodesBounds`.
107
+ * - Inserts the group node **before** its children in the array (React Flow
108
+ * requires a parent to precede its children).
109
+ * - Re-parents each child (`parentId`, `extent: "parent"`) and rewrites its
110
+ * position to be **relative to the group origin**.
111
+ */
112
+ export function groupNodes<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
113
+ nodes: NodeType[],
114
+ edges: EdgeType[],
115
+ nodeIds: string[],
116
+ options: GroupNodesOptions,
117
+ ): FlowGroupOperationResult<NodeType, EdgeType> {
118
+ const idSet = new Set(nodeIds);
119
+ const children = nodes.filter((n) => idSet.has(n.id));
120
+ if (children.length === 0) return { nodes, edges };
121
+
122
+ const bounds = getNodesBounds(children);
123
+ const padding = options.padding ?? DEFAULT_GROUP_PADDING;
124
+ const originX = bounds.x - padding;
125
+ const originY = bounds.y - padding - GROUP_HEADER_OFFSET;
126
+
127
+ const data: FlowGroupNodeData = {
128
+ title: options.title ?? "Group",
129
+ childCount: children.length,
130
+ ...(options.tone ? { tone: options.tone } : {}),
131
+ };
132
+
133
+ const groupNode = {
134
+ id: options.groupId,
135
+ type: FLOW_GROUP_NODE_TYPE,
136
+ position: { x: originX, y: originY },
137
+ width: bounds.width + padding * 2,
138
+ height: bounds.height + padding * 2 + GROUP_HEADER_OFFSET,
139
+ data,
140
+ } as unknown as NodeType;
141
+
142
+ const reparented = new Map<string, NodeType>();
143
+ for (const child of children) {
144
+ reparented.set(child.id, {
145
+ ...child,
146
+ parentId: options.groupId,
147
+ extent: "parent",
148
+ position: { x: child.position.x - originX, y: child.position.y - originY },
149
+ } as NodeType);
150
+ }
151
+
152
+ let earliest = nodes.length;
153
+ for (let i = 0; i < nodes.length; i++) {
154
+ if (idSet.has(nodes[i]!.id)) {
155
+ earliest = i;
156
+ break;
157
+ }
158
+ }
159
+
160
+ const mapped = nodes.map((n) => reparented.get(n.id) ?? n);
161
+ const resultNodes = [...mapped.slice(0, earliest), groupNode, ...mapped.slice(earliest)];
162
+ return { nodes: resultNodes, edges };
163
+ }
164
+
165
+ /**
166
+ * Dissolve a group: remove the group node and restore its **direct** children to
167
+ * absolute positions (dropping `parentId`/`extent`). If the group is currently
168
+ * collapsed it is expanded first, so ungrouping a collapsed group is safe.
169
+ */
170
+ export function ungroup<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
171
+ nodes: NodeType[],
172
+ edges: EdgeType[],
173
+ groupId: string,
174
+ ): FlowGroupOperationResult<NodeType, EdgeType> {
175
+ const group = nodes.find((n) => n.id === groupId);
176
+ if (!group) return { nodes, edges };
177
+
178
+ const base = (group.data as FlowGroupNodeData | undefined)?.collapsed
179
+ ? expandGroup(nodes, edges, groupId)
180
+ : { nodes, edges };
181
+
182
+ const liveGroup = base.nodes.find((n) => n.id === groupId)!;
183
+ const gx = liveGroup.position.x;
184
+ const gy = liveGroup.position.y;
185
+
186
+ const resultNodes = base.nodes
187
+ .filter((n) => n.id !== groupId)
188
+ .map((n) => {
189
+ if (n.parentId === groupId) {
190
+ const {
191
+ parentId: _parentId,
192
+ extent: _extent,
193
+ ...rest
194
+ } = n as NodeType & {
195
+ parentId?: string;
196
+ extent?: unknown;
197
+ };
198
+ return {
199
+ ...rest,
200
+ position: { x: n.position.x + gx, y: n.position.y + gy },
201
+ } as NodeType;
202
+ }
203
+ return n;
204
+ });
205
+
206
+ return { nodes: resultNodes, edges: base.edges };
207
+ }
208
+
209
+ /**
210
+ * Collapse a group to an overview chip.
211
+ *
212
+ * - Hides every descendant (including nested groups and their subtrees).
213
+ * - Shrinks the group to a fixed overview size and marks `data.collapsed`.
214
+ * - Re-routes every edge that **crosses the collapse boundary** (exactly one
215
+ * endpoint inside the collapsed subtree) to a **proxy edge** targeting the
216
+ * group node; the original edge is hidden and stashed for restoration.
217
+ * - Edges fully inside or fully outside the subtree are left untouched.
218
+ *
219
+ * The full pre-collapse graph is stashed on the group's `data` so `expandGroup`
220
+ * is an exact inverse. Nested collapse is handled by snapshotting each
221
+ * descendant's exact prior state (a subgroup that was already collapsed stays
222
+ * collapsed on expand — no double-unhide).
223
+ */
224
+ export function collapseGroup<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
225
+ nodes: NodeType[],
226
+ edges: EdgeType[],
227
+ groupId: string,
228
+ ): FlowGroupOperationResult<NodeType, EdgeType> {
229
+ const group = nodes.find((n) => n.id === groupId);
230
+ if (!group) return { nodes, edges };
231
+ if ((group.data as FlowGroupNodeData | undefined)?.collapsed) return { nodes, edges };
232
+
233
+ const descendantIds = collectDescendantIds(nodes, groupId);
234
+
235
+ const descendantSnapshots = nodes.filter((n) => descendantIds.has(n.id));
236
+ const directChildCount = nodes.filter((n) => n.parentId === groupId).length;
237
+
238
+ const isInside = (id: string) => descendantIds.has(id);
239
+ const reroutedEdges: EdgeType[] = [];
240
+ const proxyEdges: EdgeType[] = [];
241
+ for (const edge of edges) {
242
+ const sourceInside = isInside(edge.source);
243
+ const targetInside = isInside(edge.target);
244
+ if (sourceInside === targetInside) continue; // fully inside or fully outside
245
+
246
+ reroutedEdges.push(edge);
247
+ const proxyData: FlowGroupProxyEdgeData = {
248
+ ...(edge.data ?? {}),
249
+ __flowGroupProxy: true,
250
+ groupId,
251
+ };
252
+ proxyEdges.push({
253
+ ...edge,
254
+ id: proxyEdgeId(groupId, edge.id),
255
+ source: sourceInside ? groupId : edge.source,
256
+ target: targetInside ? groupId : edge.target,
257
+ sourceHandle: undefined,
258
+ targetHandle: undefined,
259
+ data: proxyData,
260
+ } as EdgeType);
261
+ }
262
+
263
+ const collapsedState: FlowGroupCollapsedState = {
264
+ groupSnapshot: group,
265
+ descendantSnapshots,
266
+ reroutedEdges,
267
+ };
268
+
269
+ const resultNodes = nodes.map((n) => {
270
+ if (n.id === groupId) {
271
+ return {
272
+ ...n,
273
+ width: OVERVIEW_WIDTH,
274
+ height: OVERVIEW_HEIGHT,
275
+ data: {
276
+ ...(n.data as Record<string, unknown>),
277
+ collapsed: true,
278
+ childCount: directChildCount,
279
+ [COLLAPSED_STATE_KEY]: collapsedState,
280
+ },
281
+ } as NodeType;
282
+ }
283
+ if (descendantIds.has(n.id)) {
284
+ return { ...n, hidden: true } as NodeType;
285
+ }
286
+ return n;
287
+ });
288
+
289
+ const reroutedIds = new Set(reroutedEdges.map((e) => e.id));
290
+ const resultEdges = edges.map((e) =>
291
+ reroutedIds.has(e.id) ? ({ ...e, hidden: true } as EdgeType) : e,
292
+ );
293
+ resultEdges.push(...proxyEdges);
294
+
295
+ return { nodes: resultNodes, edges: resultEdges };
296
+ }
297
+
298
+ /**
299
+ * Expand a collapsed group — the exact inverse of `collapseGroup`. Restores the
300
+ * group node, un-hides the descendant subtree (to each node's exact prior
301
+ * state), removes this group's proxy edges and restores the original edges.
302
+ * No-op if the group isn't collapsed.
303
+ */
304
+ export function expandGroup<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
305
+ nodes: NodeType[],
306
+ edges: EdgeType[],
307
+ groupId: string,
308
+ ): FlowGroupOperationResult<NodeType, EdgeType> {
309
+ const group = nodes.find((n) => n.id === groupId);
310
+ const state = group ? readCollapsedState(group) : undefined;
311
+ if (!group || !state) return { nodes, edges };
312
+
313
+ const snapshotById = new Map(state.descendantSnapshots.map((n) => [n.id, n]));
314
+ const reroutedById = new Map(state.reroutedEdges.map((e) => [e.id, e]));
315
+
316
+ const resultNodes = nodes.map((n) => {
317
+ if (n.id === groupId) return state.groupSnapshot as NodeType;
318
+ const snap = snapshotById.get(n.id);
319
+ return snap ? (snap as NodeType) : n;
320
+ });
321
+
322
+ const resultEdges = edges
323
+ .filter(
324
+ (e) =>
325
+ !(
326
+ isFlowGroupProxyEdge(e) &&
327
+ (e.data as Partial<FlowGroupProxyEdgeData> | undefined)?.groupId === groupId
328
+ ),
329
+ )
330
+ .map((e) => {
331
+ const original = reroutedById.get(e.id);
332
+ return original ? (original as EdgeType) : e;
333
+ });
334
+
335
+ return { nodes: resultNodes, edges: resultEdges };
336
+ }
337
+
338
+ /** Collapse if expanded, expand if collapsed. */
339
+ export function toggleGroupCollapsed<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
340
+ nodes: NodeType[],
341
+ edges: EdgeType[],
342
+ groupId: string,
343
+ ): FlowGroupOperationResult<NodeType, EdgeType> {
344
+ const group = nodes.find((n) => n.id === groupId);
345
+ if (!group) return { nodes, edges };
346
+ return (group.data as FlowGroupNodeData | undefined)?.collapsed
347
+ ? expandGroup(nodes, edges, groupId)
348
+ : collapseGroup(nodes, edges, groupId);
349
+ }
@@ -0,0 +1,13 @@
1
+ export { useFlowGroups, type UseFlowGroupsResult, type GroupOptions } from "./use-flow-groups";
2
+ export {
3
+ groupNodes,
4
+ ungroup,
5
+ collapseGroup,
6
+ expandGroup,
7
+ toggleGroupCollapsed,
8
+ isFlowGroupProxyEdge,
9
+ FLOW_GROUP_NODE_TYPE,
10
+ type GroupNodesOptions,
11
+ type FlowGroupOperationResult,
12
+ type FlowGroupProxyEdgeData,
13
+ } from "./group-operations";
@@ -0,0 +1,113 @@
1
+ import { useCallback } from "react";
2
+ import { useReactFlow, type Edge, type Node } from "@xyflow/react";
3
+ import {
4
+ collapseGroup as collapseGroupOp,
5
+ expandGroup as expandGroupOp,
6
+ groupNodes as groupNodesOp,
7
+ toggleGroupCollapsed as toggleGroupCollapsedOp,
8
+ ungroup as ungroupOp,
9
+ type GroupNodesOptions,
10
+ } from "./group-operations";
11
+
12
+ /** Options for `groupNodes`/`groupSelection` with an optional generated id. */
13
+ export type GroupOptions = Partial<Pick<GroupNodesOptions, "groupId">> &
14
+ Omit<GroupNodesOptions, "groupId">;
15
+
16
+ export interface UseFlowGroupsResult {
17
+ /**
18
+ * Wrap the given (top-level) node ids in a new `FlowGroupNode`. Returns the new
19
+ * group id, or `undefined` if nothing was grouped. Pass `options.groupId` for a
20
+ * deterministic id; otherwise one is generated.
21
+ */
22
+ groupNodes: (nodeIds: string[], options?: GroupOptions) => string | undefined;
23
+ /** Group the currently-selected nodes. Returns the new group id (or `undefined`). */
24
+ groupSelection: (options?: GroupOptions) => string | undefined;
25
+ /** Dissolve a group, restoring its children to absolute positions. */
26
+ ungroup: (groupId: string) => void;
27
+ /** Collapse a group to an overview chip (hides subtree, reroutes boundary edges). */
28
+ collapseGroup: (groupId: string) => void;
29
+ /** Expand a collapsed group (exact inverse of collapse). */
30
+ expandGroup: (groupId: string) => void;
31
+ /** Collapse if expanded, expand if collapsed. */
32
+ toggleCollapse: (groupId: string) => void;
33
+ }
34
+
35
+ function generateGroupId(): string {
36
+ return `flow-group-${Math.random().toString(36).slice(2, 9)}`;
37
+ }
38
+
39
+ /**
40
+ * Thin hook over the pure grouping operations in `group-operations.ts`. Reads and
41
+ * writes the live graph via `useReactFlow()`; all the logic lives in the pure,
42
+ * unit-tested core. Must be called inside a React Flow context (e.g. from a
43
+ * `<Panel>` child of `<CanvasShell>` or from within a custom node).
44
+ */
45
+ export function useFlowGroups<
46
+ NodeType extends Node = Node,
47
+ EdgeType extends Edge = Edge,
48
+ >(): UseFlowGroupsResult {
49
+ const { getNodes, getEdges, setNodes, setEdges } = useReactFlow<NodeType, EdgeType>();
50
+
51
+ const apply = useCallback(
52
+ (result: { nodes: NodeType[]; edges: EdgeType[] }) => {
53
+ setNodes(result.nodes);
54
+ setEdges(result.edges);
55
+ },
56
+ [setNodes, setEdges],
57
+ );
58
+
59
+ const groupNodes = useCallback(
60
+ (nodeIds: string[], options?: GroupOptions): string | undefined => {
61
+ if (nodeIds.length === 0) return undefined;
62
+ const groupId = options?.groupId ?? generateGroupId();
63
+ const result = groupNodesOp<NodeType, EdgeType>(getNodes(), getEdges(), nodeIds, {
64
+ ...options,
65
+ groupId,
66
+ });
67
+ apply(result);
68
+ return groupId;
69
+ },
70
+ [getNodes, getEdges, apply],
71
+ );
72
+
73
+ const groupSelection = useCallback(
74
+ (options?: GroupOptions): string | undefined => {
75
+ const selectedIds = getNodes()
76
+ .filter((n) => n.selected && !n.parentId)
77
+ .map((n) => n.id);
78
+ return groupNodes(selectedIds, options);
79
+ },
80
+ [getNodes, groupNodes],
81
+ );
82
+
83
+ const ungroup = useCallback(
84
+ (groupId: string) => apply(ungroupOp<NodeType, EdgeType>(getNodes(), getEdges(), groupId)),
85
+ [getNodes, getEdges, apply],
86
+ );
87
+
88
+ const collapseGroup = useCallback(
89
+ (groupId: string) =>
90
+ apply(collapseGroupOp<NodeType, EdgeType>(getNodes(), getEdges(), groupId)),
91
+ [getNodes, getEdges, apply],
92
+ );
93
+
94
+ const expandGroup = useCallback(
95
+ (groupId: string) => apply(expandGroupOp<NodeType, EdgeType>(getNodes(), getEdges(), groupId)),
96
+ [getNodes, getEdges, apply],
97
+ );
98
+
99
+ const toggleCollapse = useCallback(
100
+ (groupId: string) =>
101
+ apply(toggleGroupCollapsedOp<NodeType, EdgeType>(getNodes(), getEdges(), groupId)),
102
+ [getNodes, getEdges, apply],
103
+ );
104
+
105
+ return {
106
+ groupNodes,
107
+ groupSelection,
108
+ ungroup,
109
+ collapseGroup,
110
+ expandGroup,
111
+ toggleCollapse,
112
+ };
113
+ }
@@ -0,0 +1 @@
1
+ export { ZoomControls, type ZoomControlsProps } from "./zoom-controls";
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import "@xyflow/react/dist/style.css";
3
+ import { CanvasShell } from "../canvas-shell";
4
+ import { FlowNode, type BrandFlowNode } from "../flow-node";
5
+ import { ZoomControls } from "./zoom-controls";
6
+
7
+ const nodeTypes = { brand: FlowNode };
8
+
9
+ /** Nodes used as canvas background so the zoom/fit controls have something to act on. */
10
+ const nodes: BrandFlowNode[] = [
11
+ {
12
+ id: "1",
13
+ type: "brand",
14
+ position: { x: 0, y: 0 },
15
+ data: { kind: "Source", title: "Postgres", subtitle: "orders", tone: "accent" },
16
+ },
17
+ {
18
+ id: "2",
19
+ type: "brand",
20
+ position: { x: 250, y: 120 },
21
+ data: { kind: "Output", title: "Dashboard", tone: "success" },
22
+ },
23
+ ];
24
+
25
+ const meta = {
26
+ title: "Flow/ZoomControls",
27
+ component: ZoomControls,
28
+ tags: ["autodocs"],
29
+ parameters: { layout: "fullscreen" },
30
+ } satisfies Meta<typeof ZoomControls>;
31
+ export default meta;
32
+ type Story = StoryObj<typeof meta>;
33
+
34
+ /**
35
+ * ZoomControls must render inside a CanvasShell (React Flow context) because it
36
+ * calls useReactFlow internally. Default position is bottom-right.
37
+ */
38
+ export const Default: Story = {
39
+ render: () => (
40
+ <div className="h-[400px]">
41
+ <CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes}>
42
+ <ZoomControls />
43
+ </CanvasShell>
44
+ </div>
45
+ ),
46
+ };
47
+
48
+ /** Controls placed at the top-right of the canvas. */
49
+ export const TopRight: Story = {
50
+ render: () => (
51
+ <div className="h-[400px]">
52
+ <CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes}>
53
+ <ZoomControls position="top-right" />
54
+ </CanvasShell>
55
+ </div>
56
+ ),
57
+ };
58
+
59
+ /** Controls placed at the bottom-left of the canvas. */
60
+ export const BottomLeft: Story = {
61
+ render: () => (
62
+ <div className="h-[400px]">
63
+ <CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes}>
64
+ <ZoomControls position="bottom-left" />
65
+ </CanvasShell>
66
+ </div>
67
+ ),
68
+ };
@@ -0,0 +1,80 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // @xyflow/react requires real layout/measurement — mock the engine and assert
5
+ // the brand component's own output. Real rendering + a11y are covered by
6
+ // Storybook interaction tests.
7
+ const zoomIn = vi.fn();
8
+ const zoomOut = vi.fn();
9
+ const fitView = vi.fn();
10
+
11
+ vi.mock("@xyflow/react", () => {
12
+ // 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
13
+ const React = require("react");
14
+ return {
15
+ useReactFlow: () => ({ zoomIn, zoomOut, fitView }),
16
+ Panel: ({ children, position }: { children?: React.ReactNode; position?: string }) =>
17
+ React.createElement(
18
+ "div",
19
+ { "data-testid": "rf-panel", "data-position": position },
20
+ children,
21
+ ),
22
+ };
23
+ });
24
+
25
+ import { ZoomControls } from "./zoom-controls";
26
+
27
+ afterEach(() => {
28
+ cleanup();
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ describe("ZoomControls", () => {
33
+ it("renders without throwing", () => {
34
+ const { container } = render(<ZoomControls />);
35
+ expect(container.firstChild).toBeInTheDocument();
36
+ });
37
+
38
+ it("renders Zoom in, Zoom out, and Fit view buttons", () => {
39
+ render(<ZoomControls />);
40
+ expect(screen.getByRole("button", { name: "Zoom in" })).toBeInTheDocument();
41
+ expect(screen.getByRole("button", { name: "Zoom out" })).toBeInTheDocument();
42
+ expect(screen.getByRole("button", { name: "Fit view" })).toBeInTheDocument();
43
+ });
44
+
45
+ it("calls zoomIn when Zoom in button is clicked", () => {
46
+ render(<ZoomControls />);
47
+ screen.getByRole("button", { name: "Zoom in" }).click();
48
+ expect(zoomIn).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it("calls zoomOut when Zoom out button is clicked", () => {
52
+ render(<ZoomControls />);
53
+ screen.getByRole("button", { name: "Zoom out" }).click();
54
+ expect(zoomOut).toHaveBeenCalledTimes(1);
55
+ });
56
+
57
+ it("calls fitView when Fit view button is clicked", () => {
58
+ render(<ZoomControls />);
59
+ screen.getByRole("button", { name: "Fit view" }).click();
60
+ expect(fitView).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ it("renders inside a Panel with the default position bottom-right", () => {
64
+ const { getByTestId } = render(<ZoomControls />);
65
+ expect(getByTestId("rf-panel")).toHaveAttribute("data-position", "bottom-right");
66
+ });
67
+
68
+ it("passes a custom position to the Panel", () => {
69
+ const { getByTestId } = render(<ZoomControls position="top-left" />);
70
+ expect(getByTestId("rf-panel")).toHaveAttribute("data-position", "top-left");
71
+ });
72
+
73
+ it("applies custom className to the controls wrapper", () => {
74
+ render(<ZoomControls className="my-zoom" />);
75
+ const panel = screen.getByTestId("rf-panel");
76
+ // The className is on the inner div, child of the Panel
77
+ const inner = panel.firstChild as HTMLElement;
78
+ expect(inner).toHaveClass("my-zoom");
79
+ });
80
+ });