@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,190 @@
1
+ import { type ReactNode } from "react";
2
+ import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
3
+ import { Star, type LucideIcon } from "lucide-react";
4
+ import { STATUS_TONE_ICONS } from "@elabs-ai/components-ui";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+
7
+ /** A node side that can carry a handle. Doubles as the handle's stable id. */
8
+ export type FlowHandleSide = "top" | "right" | "bottom" | "left";
9
+
10
+ /**
11
+ * Which sides of a node expose source and/or target handles. When omitted,
12
+ * `FlowNode` keeps its default single top **target** + bottom **source**. When
13
+ * set, a `<Handle>` is rendered on each listed side with a **stable id equal to
14
+ * the side name** (`"top" | "right" | "bottom" | "left"`), addressable per
15
+ * handle type — so edges (e.g. `FlowSmartEdge`) can pick a specific anchor.
16
+ */
17
+ export interface FlowNodeHandles {
18
+ /** Sides exposing a source handle (handle `id` === side). */
19
+ source?: FlowHandleSide[];
20
+ /** Sides exposing a target handle (handle `id` === side). */
21
+ target?: FlowHandleSide[];
22
+ }
23
+
24
+ /**
25
+ * A `FlowNodeHandles` config exposing source **and** target anchors on all four
26
+ * sides. The recommended setup for `FlowSmartEdge` / `FlowFloatingEdge`: with
27
+ * anchors on every side, an edge connects on whichever side **faces** the other
28
+ * node (left/right when side-by-side, top/bottom when stacked) instead of always
29
+ * top/bottom. Spread onto a node's `data.handles`.
30
+ */
31
+ export const FLOW_ALL_SIDE_HANDLES: FlowNodeHandles = {
32
+ source: ["top", "right", "bottom", "left"],
33
+ target: ["top", "right", "bottom", "left"],
34
+ };
35
+
36
+ export interface FlowNodeData extends Record<string, unknown> {
37
+ title: string;
38
+ subtitle?: string;
39
+ /** Short type label shown as an eyebrow, e.g. "Source", "Transform". */
40
+ kind?: string;
41
+ icon?: ReactNode;
42
+ tone?: "default" | "accent" | "success" | "warning" | "destructive";
43
+ /**
44
+ * Optional multi-side handle configuration. Absent → default top-target /
45
+ * bottom-source (unchanged, backward-compatible).
46
+ */
47
+ handles?: FlowNodeHandles;
48
+ }
49
+
50
+ export type BrandFlowNode = Node<FlowNodeData, "brand">;
51
+
52
+ const toneRing: Record<NonNullable<FlowNodeData["tone"]>, string> = {
53
+ default: "border-border",
54
+ accent: "border-primary",
55
+ success: "border-success",
56
+ warning: "border-warning",
57
+ destructive: "border-destructive",
58
+ };
59
+
60
+ /**
61
+ * `tone` used to be encoded in colour ALONE — a 1px border and nothing else
62
+ * (WCAG 1.4.1, #387). Every non-default tone now also gets a leading Lucide
63
+ * glyph (`aria-hidden`, decorative — the sr-only text below carries the
64
+ * meaning) and a distinct accessible name. `success`/`warning`/`destructive`
65
+ * reuse `@elabs-ai/components-ui`'s `STATUS_TONE_ICONS` (the SAME glyph
66
+ * `StatusBadge`/`StatusIcon` already pair with that tone) rather than
67
+ * inventing a second icon vocabulary; `accent` has no canonical `StatusTone`
68
+ * counterpart to reuse (the closed `StatusTone` enum is deliberately
69
+ * CALM-ONLY and excludes a "primary/highlighted" bucket — see
70
+ * `status-badge.tsx`'s docblock), so it gets the one net-new pairing.
71
+ * `default` keeps neither — reaching for a glyph/label on every ordinary node
72
+ * would be visual and assistive-tech noise on the common case.
73
+ */
74
+ const toneIcon: Partial<Record<NonNullable<FlowNodeData["tone"]>, LucideIcon>> = {
75
+ accent: Star,
76
+ success: STATUS_TONE_ICONS.success,
77
+ warning: STATUS_TONE_ICONS.warning,
78
+ destructive: STATUS_TONE_ICONS.destructive,
79
+ };
80
+
81
+ /** Ink for the tone glyph — the coloured-text rung (`-text`), not the fill rung. */
82
+ const toneIconColor: Partial<Record<NonNullable<FlowNodeData["tone"]>, string>> = {
83
+ accent: "text-primary",
84
+ success: "text-success-text",
85
+ warning: "text-warning-text",
86
+ destructive: "text-destructive-text",
87
+ };
88
+
89
+ /** The accessible name the tone glyph stands in for (`sr-only`, #387). */
90
+ const toneLabel: Partial<Record<NonNullable<FlowNodeData["tone"]>, string>> = {
91
+ accent: "Highlighted",
92
+ success: "Success",
93
+ warning: "Warning",
94
+ destructive: "Destructive",
95
+ };
96
+
97
+ const sidePosition: Record<FlowHandleSide, Position> = {
98
+ top: Position.Top,
99
+ right: Position.Right,
100
+ bottom: Position.Bottom,
101
+ left: Position.Left,
102
+ };
103
+
104
+ const handleClassName = "!size-2 !border-2 !border-flow-edge !bg-flow-node";
105
+
106
+ /**
107
+ * Branded custom node. Register it in `nodeTypes={{ brand: FlowNode }}` and
108
+ * create nodes with `type: "brand"` and `data: FlowNodeData`.
109
+ */
110
+ export function FlowNode({
111
+ data,
112
+ selected,
113
+ sourcePosition,
114
+ targetPosition,
115
+ }: NodeProps<BrandFlowNode>) {
116
+ const tone = data.tone ?? "default";
117
+ const ToneIcon = toneIcon[tone];
118
+ return (
119
+ <div
120
+ data-tone={tone}
121
+ className={cn(
122
+ "min-w-44 rounded-lg border bg-flow-node px-3 py-2 text-flow-node-foreground shadow-sm transition-[box-shadow,border-color] duration-fast ease-standard",
123
+ toneRing[tone],
124
+ selected && "ring-2 ring-ring",
125
+ )}
126
+ >
127
+ {data.handles ? (
128
+ <>
129
+ {(data.handles.target ?? []).map((side) => (
130
+ <Handle
131
+ key={`target-${side}`}
132
+ id={side}
133
+ type="target"
134
+ position={sidePosition[side]}
135
+ className={handleClassName}
136
+ />
137
+ ))}
138
+ {(data.handles.source ?? []).map((side) => (
139
+ <Handle
140
+ key={`source-${side}`}
141
+ id={side}
142
+ type="source"
143
+ position={sidePosition[side]}
144
+ className={handleClassName}
145
+ />
146
+ ))}
147
+ </>
148
+ ) : (
149
+ <>
150
+ {/* Default single target + source. Positions follow the layout
151
+ direction: React Flow passes `sourcePosition`/`targetPosition`
152
+ (which `layoutFlow` sets per direction), so an LR layout puts the
153
+ target on the LEFT and the source on the RIGHT. Falls back to
154
+ top-in / bottom-out when unset. Handle placement is by `position`,
155
+ not DOM order. */}
156
+ <Handle
157
+ type="target"
158
+ position={targetPosition ?? Position.Top}
159
+ className={handleClassName}
160
+ />
161
+ <Handle
162
+ type="source"
163
+ position={sourcePosition ?? Position.Bottom}
164
+ className={handleClassName}
165
+ />
166
+ </>
167
+ )}
168
+ <div className="flex items-center gap-2">
169
+ {data.icon ? (
170
+ <span className="[&_svg]:size-4 text-muted-foreground">{data.icon}</span>
171
+ ) : null}
172
+ <div className="min-w-0 flex-1">
173
+ {data.kind ? (
174
+ <div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
175
+ {data.kind}
176
+ </div>
177
+ ) : null}
178
+ <div className="truncate text-sm font-medium">{data.title}</div>
179
+ {data.subtitle ? (
180
+ <div className="truncate text-xs text-muted-foreground">{data.subtitle}</div>
181
+ ) : null}
182
+ </div>
183
+ {ToneIcon ? (
184
+ <ToneIcon aria-hidden="true" className={cn("size-3.5 shrink-0", toneIconColor[tone])} />
185
+ ) : null}
186
+ </div>
187
+ {toneLabel[tone] ? <span className="sr-only">{toneLabel[tone]}</span> : null}
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ FlowNode,
3
+ FLOW_ALL_SIDE_HANDLES,
4
+ type FlowNodeData,
5
+ type FlowNodeHandles,
6
+ type FlowHandleSide,
7
+ type BrandFlowNode,
8
+ } from "./flow-node";
@@ -0,0 +1,260 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import "@xyflow/react/dist/style.css";
3
+ import { useCallback, useEffect, useMemo, useRef } from "react";
4
+ import {
5
+ ReactFlowProvider,
6
+ useEdgesState,
7
+ useNodesState,
8
+ useReactFlow,
9
+ type Edge,
10
+ type OnConnectEnd,
11
+ } from "@xyflow/react";
12
+ import { CanvasShell } from "../canvas-shell";
13
+ import { FlowEdge } from "../flow-edge";
14
+ import { FlowNode, type BrandFlowNode } from "../flow-node";
15
+ import { useFlowLayout } from "../flow-layout";
16
+ import { ZoomControls } from "../zoom-controls";
17
+ import { FlowPlaceholderNode, type BrandFlowPlaceholderNode } from "./flow-placeholder-node";
18
+
19
+ const nodeTypes = { brand: FlowNode, placeholder: FlowPlaceholderNode };
20
+ const edgeTypes = { brand: FlowEdge };
21
+
22
+ type CanvasNode = BrandFlowNode | BrandFlowPlaceholderNode;
23
+
24
+ const meta = {
25
+ title: "Flow/FlowPlaceholderNode",
26
+ component: FlowPlaceholderNode,
27
+ tags: ["autodocs"],
28
+ parameters: { layout: "fullscreen" },
29
+ } satisfies Meta<typeof FlowPlaceholderNode>;
30
+ export default meta;
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ /** A single placeholder attached below a real node — the resting shape. */
34
+ export const Default: Story = {
35
+ render: () => {
36
+ const nodes: CanvasNode[] = [
37
+ {
38
+ id: "source",
39
+ type: "brand",
40
+ position: { x: 100, y: 20 },
41
+ data: { kind: "Source", title: "Ingest", tone: "accent" },
42
+ },
43
+ {
44
+ id: "placeholder",
45
+ type: "placeholder",
46
+ position: { x: 100, y: 160 },
47
+ data: { label: "Add step" },
48
+ },
49
+ ];
50
+ const edges: Edge[] = [
51
+ { id: "e-source-placeholder", source: "source", target: "placeholder", type: "brand" },
52
+ ];
53
+ return (
54
+ <div className="h-[300px]">
55
+ <CanvasShell nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
56
+ </div>
57
+ );
58
+ },
59
+ };
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Pattern 1 — Placeholder tail: click a placeholder to grow the graph.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const tailInitialNodes: CanvasNode[] = [
66
+ {
67
+ id: "start",
68
+ type: "brand",
69
+ position: { x: 60, y: 20 },
70
+ data: { kind: "Source", title: "Start", tone: "accent" },
71
+ },
72
+ {
73
+ id: "placeholder-0",
74
+ type: "placeholder",
75
+ position: { x: 60, y: 160 },
76
+ data: {},
77
+ },
78
+ ];
79
+ const tailInitialEdges: Edge[] = [
80
+ { id: "e-start-placeholder-0", source: "start", target: "placeholder-0", type: "brand" },
81
+ ];
82
+
83
+ /**
84
+ * Runs the auto-layout pass once the newly grown node has been measured.
85
+ * Rendered as a CanvasShell child (invisible) so `useFlowLayout` has React
86
+ * Flow context; `pendingRef` is flipped by `grow()` in the parent.
87
+ */
88
+ function AutoLayoutOnGrowth({ pendingRef }: { pendingRef: React.MutableRefObject<boolean> }) {
89
+ const { layout, ready } = useFlowLayout();
90
+ useEffect(() => {
91
+ if (pendingRef.current && ready) {
92
+ pendingRef.current = false;
93
+ layout("TB");
94
+ }
95
+ }, [ready, layout, pendingRef]);
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Click a placeholder: it becomes a real `FlowNode` and a fresh placeholder
101
+ * grows beneath it, then the graph auto-lays-out (`useFlowLayout`). This is
102
+ * the Workflow-Builder / Dynamic-Layouting growth pattern — the placeholder's
103
+ * `id` is kept when it's converted, so the edge pointing at it stays valid.
104
+ */
105
+ function PlaceholderTailDemo() {
106
+ const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>(tailInitialNodes);
107
+ const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>(tailInitialEdges);
108
+ const stepRef = useRef(0);
109
+ const pendingLayoutRef = useRef(false);
110
+
111
+ const grow = useCallback(
112
+ (placeholderId: string) => {
113
+ const step = ++stepRef.current;
114
+ const newPlaceholderId = `placeholder-${step}`;
115
+ setNodes((current) => [
116
+ ...current.map(
117
+ (node): CanvasNode =>
118
+ node.id === placeholderId
119
+ ? { ...node, type: "brand", data: { kind: "Process", title: `Step ${step}` } }
120
+ : node,
121
+ ),
122
+ {
123
+ id: newPlaceholderId,
124
+ type: "placeholder",
125
+ // Temporary position — the auto-layout pass below repositions it.
126
+ position: { x: 0, y: 0 },
127
+ data: {},
128
+ },
129
+ ]);
130
+ setEdges((current) => [
131
+ ...current,
132
+ {
133
+ id: `e-${placeholderId}-${newPlaceholderId}`,
134
+ source: placeholderId,
135
+ target: newPlaceholderId,
136
+ type: "brand",
137
+ },
138
+ ]);
139
+ pendingLayoutRef.current = true;
140
+ },
141
+ [setNodes, setEdges],
142
+ );
143
+
144
+ // Attach `onActivate` at render time (not stored in state) so the handler
145
+ // always closes over the node's own id.
146
+ const displayNodes = useMemo<CanvasNode[]>(
147
+ () =>
148
+ nodes.map((node) =>
149
+ node.type === "placeholder"
150
+ ? { ...node, data: { ...node.data, onActivate: () => grow(node.id) } }
151
+ : node,
152
+ ),
153
+ [nodes, grow],
154
+ );
155
+
156
+ return (
157
+ <div className="h-[520px]">
158
+ <CanvasShell
159
+ nodes={displayNodes}
160
+ edges={edges}
161
+ nodeTypes={nodeTypes}
162
+ edgeTypes={edgeTypes}
163
+ onNodesChange={onNodesChange}
164
+ onEdgesChange={onEdgesChange}
165
+ >
166
+ <ZoomControls />
167
+ <AutoLayoutOnGrowth pendingRef={pendingLayoutRef} />
168
+ </CanvasShell>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ /** Click "Add node" to grow the chain — each click converts the placeholder into a real step and re-lays-out. */
174
+ export const PlaceholderTail: Story = {
175
+ render: () => <PlaceholderTailDemo />,
176
+ };
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Pattern 2 — Add node on edge drop: drag a connection onto empty canvas.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ const dropInitialNodes: BrandFlowNode[] = [
183
+ {
184
+ id: "drop-source",
185
+ type: "brand",
186
+ position: { x: 200, y: 40 },
187
+ data: {
188
+ kind: "Source",
189
+ title: "Drag from here",
190
+ subtitle: "onto empty canvas",
191
+ tone: "accent",
192
+ },
193
+ },
194
+ ];
195
+
196
+ function AddNodeOnEdgeDropCanvas() {
197
+ const [nodes, setNodes, onNodesChange] = useNodesState<BrandFlowNode>(dropInitialNodes);
198
+ const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
199
+ const countRef = useRef(0);
200
+ const { screenToFlowPosition } = useReactFlow();
201
+
202
+ const onConnectEnd: OnConnectEnd = useCallback(
203
+ (event, connectionState) => {
204
+ // A valid connection landed on a real handle — nothing to create.
205
+ if (connectionState.isValid || !connectionState.fromNode) return;
206
+
207
+ const point = "changedTouches" in event ? event.changedTouches[0] : event;
208
+ const id = `dropped-${++countRef.current}`;
209
+ const position = screenToFlowPosition({ x: point.clientX, y: point.clientY });
210
+
211
+ setNodes((current) =>
212
+ current.concat({
213
+ id,
214
+ type: "brand",
215
+ position,
216
+ data: { kind: "Process", title: `Node ${countRef.current}` },
217
+ }),
218
+ );
219
+ setEdges((current) =>
220
+ current.concat({
221
+ id: `e-${connectionState.fromNode!.id}-${id}`,
222
+ source: connectionState.fromNode!.id,
223
+ target: id,
224
+ type: "brand",
225
+ }),
226
+ );
227
+ },
228
+ [screenToFlowPosition, setNodes, setEdges],
229
+ );
230
+
231
+ return (
232
+ <div className="h-[500px]">
233
+ <CanvasShell
234
+ nodes={nodes}
235
+ edges={edges}
236
+ nodeTypes={nodeTypes}
237
+ edgeTypes={edgeTypes}
238
+ onNodesChange={onNodesChange}
239
+ onEdgesChange={onEdgesChange}
240
+ onConnectEnd={onConnectEnd}
241
+ >
242
+ <ZoomControls />
243
+ </CanvasShell>
244
+ </div>
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Drag from the node's bottom handle and release on empty canvas: a new
250
+ * `FlowNode` is created at the drop point and wired to the source. Uses
251
+ * `onConnectEnd` + `screenToFlowPosition` (needs a `ReactFlowProvider`
252
+ * ancestor so `useReactFlow` resolves outside `<CanvasShell>`'s children).
253
+ */
254
+ export const AddNodeOnEdgeDrop: Story = {
255
+ render: () => (
256
+ <ReactFlowProvider>
257
+ <AddNodeOnEdgeDropCanvas />
258
+ </ReactFlowProvider>
259
+ ),
260
+ };
@@ -0,0 +1,101 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ // @xyflow/react requires real layout/measurement — mock the engine and assert
6
+ // the brand component's own output. Real rendering + a11y are covered by
7
+ // Storybook interaction tests.
8
+ vi.mock("@xyflow/react", () => {
9
+ // 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
10
+ const React = require("react");
11
+ return {
12
+ Handle: ({
13
+ type,
14
+ position,
15
+ className,
16
+ }: {
17
+ type: string;
18
+ position: string;
19
+ className?: string;
20
+ }) =>
21
+ React.createElement("div", {
22
+ "data-testid": `handle-${type}`,
23
+ "data-position": position,
24
+ className,
25
+ }),
26
+ Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
27
+ };
28
+ });
29
+
30
+ import { FlowPlaceholderNode, type BrandFlowPlaceholderNode } from "./flow-placeholder-node";
31
+ import type { NodeProps } from "@xyflow/react";
32
+
33
+ afterEach(cleanup);
34
+
35
+ /** Minimal NodeProps factory for FlowPlaceholderNode (BrandFlowPlaceholderNode). */
36
+ function makeProps(
37
+ data: BrandFlowPlaceholderNode["data"],
38
+ overrides: Partial<NodeProps<BrandFlowPlaceholderNode>> = {},
39
+ ): NodeProps<BrandFlowPlaceholderNode> {
40
+ return {
41
+ id: "test-placeholder",
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: 160,
53
+ height: 48,
54
+ type: "placeholder",
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ describe("FlowPlaceholderNode", () => {
60
+ it("renders a real, accessible button with the default label", () => {
61
+ render(<FlowPlaceholderNode {...makeProps({})} />);
62
+ const button = screen.getByRole("button", { name: "Add node" });
63
+ expect(button.tagName).toBe("BUTTON");
64
+ });
65
+
66
+ it("renders a custom label as the accessible name", () => {
67
+ render(<FlowPlaceholderNode {...makeProps({ label: "Add step" })} />);
68
+ expect(screen.getByRole("button", { name: "Add step" })).toBeInTheDocument();
69
+ });
70
+
71
+ it("renders a target handle so an edge can point at it", () => {
72
+ render(<FlowPlaceholderNode {...makeProps({})} />);
73
+ expect(screen.getByTestId("handle-target")).toBeInTheDocument();
74
+ });
75
+
76
+ it("fires onActivate on click", async () => {
77
+ const user = userEvent.setup();
78
+ const onActivate = vi.fn();
79
+ render(<FlowPlaceholderNode {...makeProps({ onActivate })} />);
80
+ await user.click(screen.getByRole("button", { name: "Add node" }));
81
+ expect(onActivate).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ it("fires onActivate on keyboard Enter", async () => {
85
+ const user = userEvent.setup();
86
+ const onActivate = vi.fn();
87
+ render(<FlowPlaceholderNode {...makeProps({ onActivate })} />);
88
+ screen.getByRole("button", { name: "Add node" }).focus();
89
+ await user.keyboard("{Enter}");
90
+ expect(onActivate).toHaveBeenCalledTimes(1);
91
+ });
92
+
93
+ it("fires onActivate on keyboard Space", async () => {
94
+ const user = userEvent.setup();
95
+ const onActivate = vi.fn();
96
+ render(<FlowPlaceholderNode {...makeProps({ onActivate })} />);
97
+ screen.getByRole("button", { name: "Add node" }).focus();
98
+ await user.keyboard(" ");
99
+ expect(onActivate).toHaveBeenCalledTimes(1);
100
+ });
101
+ });
@@ -0,0 +1,49 @@
1
+ import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
2
+ import { Plus } from "lucide-react";
3
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
4
+
5
+ export interface FlowPlaceholderNodeData extends Record<string, unknown> {
6
+ /** Label rendered inside the placeholder. @default "Add node" */
7
+ label?: string;
8
+ /** Called when the placeholder is activated (click, Enter, or Space). */
9
+ onActivate?: () => void;
10
+ }
11
+
12
+ export type BrandFlowPlaceholderNode = Node<FlowPlaceholderNodeData, "placeholder">;
13
+
14
+ /**
15
+ * Dashed, muted "add here" node — a clickable affordance that grows the
16
+ * graph. Register it in `nodeTypes={{ placeholder: FlowPlaceholderNode }}`
17
+ * and create nodes with `type: "placeholder"` and `data: FlowPlaceholderNodeData`.
18
+ *
19
+ * Renders a real `<button>` (keyboard-activatable via native Enter/Space) with
20
+ * an `aria-label`, and a single **target** `<Handle>` (top) so an existing
21
+ * edge can point at it. Fires `data.onActivate?.()` on activation — the
22
+ * typical handler converts the placeholder into a real `FlowNode` and grows a
23
+ * fresh placeholder beneath it (see the "Placeholder tail" story).
24
+ */
25
+ export function FlowPlaceholderNode({ data }: NodeProps<BrandFlowPlaceholderNode>) {
26
+ const label = data.label ?? "Add node";
27
+ return (
28
+ <div className="relative">
29
+ <Handle
30
+ type="target"
31
+ position={Position.Top}
32
+ className="!size-2 !border-2 !border-flow-edge !bg-flow-node"
33
+ />
34
+ <button
35
+ type="button"
36
+ aria-label={label}
37
+ onClick={() => data.onActivate?.()}
38
+ className={cn(
39
+ "flex min-w-44 items-center justify-center gap-1.5 rounded-lg border border-dashed border-flow-group-border bg-flow-group px-3 py-2 text-muted-foreground",
40
+ "transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground",
41
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
42
+ )}
43
+ >
44
+ <Plus className="size-4" aria-hidden="true" />
45
+ <span className="text-body">{label}</span>
46
+ </button>
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ FlowPlaceholderNode,
3
+ type FlowPlaceholderNodeData,
4
+ type BrandFlowPlaceholderNode,
5
+ } from "./flow-placeholder-node";
@@ -0,0 +1,75 @@
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 { FlowSmartEdge } from "./flow-smart-edge";
7
+
8
+ const nodeTypes = { brand: FlowNode };
9
+ const edgeTypes = { smart: FlowSmartEdge };
10
+
11
+ const meta = {
12
+ title: "Flow/FlowSmartEdge",
13
+ component: FlowSmartEdge,
14
+ tags: ["autodocs"],
15
+ parameters: { layout: "fullscreen" },
16
+ } satisfies Meta<typeof FlowSmartEdge>;
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ /**
21
+ * Multi-side nodes (handles on all four sides) connected by `FlowSmartEdge`.
22
+ * Drag a node around another and the edge re-picks the closest source/target
23
+ * handle pair, so the anchors flip as the geometry changes.
24
+ */
25
+ export const Default: Story = {
26
+ render: () => {
27
+ const allSides = {
28
+ source: ["top", "right", "bottom", "left"],
29
+ target: ["top", "right", "bottom", "left"],
30
+ } as const;
31
+ const nodes: BrandFlowNode[] = [
32
+ {
33
+ id: "a",
34
+ type: "brand",
35
+ position: { x: 120, y: 160 },
36
+ data: {
37
+ kind: "Source",
38
+ title: "Ingest",
39
+ tone: "accent",
40
+ handles: { source: [...allSides.source], target: [...allSides.target] },
41
+ },
42
+ },
43
+ {
44
+ id: "b",
45
+ type: "brand",
46
+ position: { x: 420, y: 60 },
47
+ data: {
48
+ kind: "Process",
49
+ title: "Transform",
50
+ handles: { source: [...allSides.source], target: [...allSides.target] },
51
+ },
52
+ },
53
+ {
54
+ id: "c",
55
+ type: "brand",
56
+ position: { x: 420, y: 300 },
57
+ data: {
58
+ kind: "Output",
59
+ title: "Publish",
60
+ tone: "success",
61
+ handles: { source: [...allSides.source], target: [...allSides.target] },
62
+ },
63
+ },
64
+ ];
65
+ const edges: Edge[] = [
66
+ { id: "a-b", source: "a", target: "b", type: "smart" },
67
+ { id: "a-c", source: "a", target: "c", type: "smart" },
68
+ ];
69
+ return (
70
+ <div className="h-[480px]">
71
+ <CanvasShell nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
72
+ </div>
73
+ );
74
+ },
75
+ };