@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.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/index.d.ts +634 -0
- package/dist/index.js +1577 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
- package/src/canvas-shell/canvas-shell.stories.tsx +77 -0
- package/src/canvas-shell/canvas-shell.test.tsx +106 -0
- package/src/canvas-shell/canvas-shell.tsx +128 -0
- package/src/canvas-shell/index.ts +1 -0
- package/src/flow-button-edge/flow-button-edge.stories.tsx +151 -0
- package/src/flow-button-edge/flow-button-edge.test.tsx +99 -0
- package/src/flow-button-edge/flow-button-edge.tsx +80 -0
- package/src/flow-button-edge/index.ts +5 -0
- package/src/flow-edge/flow-edge.stories.tsx +79 -0
- package/src/flow-edge/flow-edge.test.tsx +76 -0
- package/src/flow-edge/flow-edge.tsx +34 -0
- package/src/flow-edge/index.ts +1 -0
- package/src/flow-floating-edge/floating-edge-geometry.test.ts +100 -0
- package/src/flow-floating-edge/floating-edge-geometry.ts +128 -0
- package/src/flow-floating-edge/flow-floating-edge.stories.tsx +81 -0
- package/src/flow-floating-edge/flow-floating-edge.tsx +82 -0
- package/src/flow-floating-edge/index.ts +6 -0
- package/src/flow-group-node/flow-group-node.stories.tsx +283 -0
- package/src/flow-group-node/flow-group-node.test.tsx +100 -0
- package/src/flow-group-node/flow-group-node.tsx +134 -0
- package/src/flow-group-node/index.ts +6 -0
- package/src/flow-layout/flow-layout.stories.tsx +215 -0
- package/src/flow-layout/flow-layout.test.tsx +133 -0
- package/src/flow-layout/flow-layout.ts +104 -0
- package/src/flow-layout/index.ts +14 -0
- package/src/flow-layout/layout-graph.test.ts +257 -0
- package/src/flow-layout/layout-graph.ts +302 -0
- package/src/flow-layout/use-auto-layout.ts +26 -0
- package/src/flow-layout/use-flow-layout.ts +60 -0
- package/src/flow-mini-map/flow-mini-map.stories.tsx +74 -0
- package/src/flow-mini-map/flow-mini-map.test.tsx +56 -0
- package/src/flow-mini-map/flow-mini-map.tsx +29 -0
- package/src/flow-mini-map/index.ts +1 -0
- package/src/flow-node/flow-node.stories.tsx +143 -0
- package/src/flow-node/flow-node.test.tsx +156 -0
- package/src/flow-node/flow-node.tsx +190 -0
- package/src/flow-node/index.ts +8 -0
- package/src/flow-placeholder-node/flow-placeholder-node.stories.tsx +260 -0
- package/src/flow-placeholder-node/flow-placeholder-node.test.tsx +101 -0
- package/src/flow-placeholder-node/flow-placeholder-node.tsx +49 -0
- package/src/flow-placeholder-node/index.ts +5 -0
- package/src/flow-smart-edge/flow-smart-edge.stories.tsx +75 -0
- package/src/flow-smart-edge/flow-smart-edge.tsx +75 -0
- package/src/flow-smart-edge/index.ts +9 -0
- package/src/flow-smart-edge/smart-edge-geometry.test.ts +120 -0
- package/src/flow-smart-edge/smart-edge-geometry.ts +116 -0
- package/src/helper-lines/get-helper-lines.ts +98 -0
- package/src/helper-lines/helper-lines.stories.tsx +107 -0
- package/src/helper-lines/helper-lines.test.tsx +78 -0
- package/src/helper-lines/helper-lines.tsx +67 -0
- package/src/helper-lines/index.ts +7 -0
- package/src/helper-lines/use-helper-lines.ts +109 -0
- package/src/index.ts +37 -0
- package/src/inspector-panel/index.ts +1 -0
- package/src/inspector-panel/inspector-panel.stories.tsx +161 -0
- package/src/inspector-panel/inspector-panel.test.tsx +82 -0
- package/src/inspector-panel/inspector-panel.tsx +129 -0
- package/src/legend/index.ts +1 -0
- package/src/legend/legend.stories.tsx +56 -0
- package/src/legend/legend.test.tsx +46 -0
- package/src/legend/legend.tsx +39 -0
- package/src/templates-flow-workspace.stories.tsx +169 -0
- package/src/use-flow-groups/group-operations.test.ts +318 -0
- package/src/use-flow-groups/group-operations.ts +349 -0
- package/src/use-flow-groups/index.ts +13 -0
- package/src/use-flow-groups/use-flow-groups.ts +113 -0
- package/src/zoom-controls/index.ts +1 -0
- package/src/zoom-controls/zoom-controls.stories.tsx +68 -0
- package/src/zoom-controls/zoom-controls.test.tsx +80 -0
- 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,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,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
|
+
};
|