@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,128 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Background,
|
|
4
|
+
ReactFlow,
|
|
5
|
+
ReactFlowProvider,
|
|
6
|
+
type AriaLabelConfig,
|
|
7
|
+
type Edge,
|
|
8
|
+
type Node,
|
|
9
|
+
type ReactFlowProps,
|
|
10
|
+
} from "@xyflow/react";
|
|
11
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
12
|
+
import { HelperLines } from "../helper-lines/helper-lines";
|
|
13
|
+
import { useHelperLines } from "../helper-lines/use-helper-lines";
|
|
14
|
+
|
|
15
|
+
export interface CanvasShellProps<
|
|
16
|
+
NodeType extends Node = Node,
|
|
17
|
+
EdgeType extends Edge = Edge,
|
|
18
|
+
> extends ReactFlowProps<NodeType, EdgeType> {
|
|
19
|
+
/** Render a branded dotted background grid. Defaults to true. */
|
|
20
|
+
background?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Enable alignment guides + snapping while a single node is dragged. Off by
|
|
23
|
+
* default; when false, CanvasShell behaves exactly as before. When true,
|
|
24
|
+
* CanvasShell wires `useHelperLines` around your `onNodesChange` and renders
|
|
25
|
+
* the `<HelperLines>` overlay internally — pass `nodes` + `onNodesChange`
|
|
26
|
+
* (the standard controlled setup, e.g. via `useNodesState`). For full control
|
|
27
|
+
* use the exported `useHelperLines` + `<HelperLines>` directly.
|
|
28
|
+
*/
|
|
29
|
+
helperLines?: boolean;
|
|
30
|
+
/** Overlays rendered inside the flow (ZoomControls, Legend, Panels). */
|
|
31
|
+
children?: ReactNode;
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type CanvasShellInnerProps<NodeType extends Node, EdgeType extends Edge> = Omit<
|
|
36
|
+
CanvasShellProps<NodeType, EdgeType>,
|
|
37
|
+
"helperLines"
|
|
38
|
+
>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Branded defaults for React Flow's `ariaLabelConfig` (12.7+) — sentence-case
|
|
42
|
+
* labels that match the rest of brand-ui's voice (see `ZoomControls`'
|
|
43
|
+
* "Zoom in"/"Zoom out"/"Fit view"), instead of the library's Title Case
|
|
44
|
+
* defaults. Callers can override any key; unset keys keep these defaults, and
|
|
45
|
+
* React Flow itself fills in any key neither side sets.
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_ARIA_LABEL_CONFIG: Partial<AriaLabelConfig> = {
|
|
48
|
+
"node.a11yDescription.default":
|
|
49
|
+
"Press Enter or Space to select this node. Press Delete to remove it, Escape to cancel.",
|
|
50
|
+
"node.a11yDescription.keyboardDisabled":
|
|
51
|
+
"Press Enter or Space to select this node. Then use the arrow keys to move it. Press Delete to remove it, Escape to cancel.",
|
|
52
|
+
"edge.a11yDescription.default":
|
|
53
|
+
"Press Enter or Space to select this edge. Press Delete to remove it, Escape to cancel.",
|
|
54
|
+
"controls.ariaLabel": "Canvas controls",
|
|
55
|
+
"controls.zoomIn.ariaLabel": "Zoom in",
|
|
56
|
+
"controls.zoomOut.ariaLabel": "Zoom out",
|
|
57
|
+
"controls.fitView.ariaLabel": "Fit view",
|
|
58
|
+
"controls.interactive.ariaLabel": "Toggle interactivity",
|
|
59
|
+
"minimap.ariaLabel": "Minimap",
|
|
60
|
+
"handle.ariaLabel": "Connection handle",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Branded React Flow canvas. Sets a token-driven background + sensible
|
|
65
|
+
* defaults; pass nodes/edges/handlers through as normal React Flow props.
|
|
66
|
+
*
|
|
67
|
+
* Consumers MUST import the React Flow stylesheet once at the app root:
|
|
68
|
+
* import "@xyflow/react/dist/style.css";
|
|
69
|
+
*
|
|
70
|
+
* Children render inside the flow context, so <ZoomControls /> and React Flow
|
|
71
|
+
* <Panel>s work when placed here.
|
|
72
|
+
*/
|
|
73
|
+
export function CanvasShell<NodeType extends Node = Node, EdgeType extends Edge = Edge>({
|
|
74
|
+
helperLines = false,
|
|
75
|
+
...props
|
|
76
|
+
}: CanvasShellProps<NodeType, EdgeType>) {
|
|
77
|
+
if (helperLines) {
|
|
78
|
+
// A provider is needed so `useHelperLines` can read the live node store; the
|
|
79
|
+
// ReactFlow rendered below connects to this same provider.
|
|
80
|
+
return (
|
|
81
|
+
<ReactFlowProvider>
|
|
82
|
+
<CanvasShellWithHelperLines<NodeType, EdgeType> {...props} />
|
|
83
|
+
</ReactFlowProvider>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return <CanvasShellBase<NodeType, EdgeType> {...props} />;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function CanvasShellBase<NodeType extends Node, EdgeType extends Edge>({
|
|
90
|
+
background = true,
|
|
91
|
+
children,
|
|
92
|
+
className,
|
|
93
|
+
ariaLabelConfig,
|
|
94
|
+
...props
|
|
95
|
+
}: CanvasShellInnerProps<NodeType, EdgeType>) {
|
|
96
|
+
return (
|
|
97
|
+
<div className={cn("h-full w-full bg-canvas", className)}>
|
|
98
|
+
<ReactFlow
|
|
99
|
+
fitView
|
|
100
|
+
proOptions={{ hideAttribution: true }}
|
|
101
|
+
ariaLabelConfig={{ ...DEFAULT_ARIA_LABEL_CONFIG, ...ariaLabelConfig }}
|
|
102
|
+
{...props}
|
|
103
|
+
>
|
|
104
|
+
{background ? <Background gap={20} size={1} color="var(--canvas-grid)" /> : null}
|
|
105
|
+
{children}
|
|
106
|
+
</ReactFlow>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function CanvasShellWithHelperLines<NodeType extends Node, EdgeType extends Edge>({
|
|
112
|
+
onNodesChange,
|
|
113
|
+
children,
|
|
114
|
+
...props
|
|
115
|
+
}: CanvasShellInnerProps<NodeType, EdgeType>) {
|
|
116
|
+
const {
|
|
117
|
+
onNodesChange: wrappedOnNodesChange,
|
|
118
|
+
helperLineHorizontal,
|
|
119
|
+
helperLineVertical,
|
|
120
|
+
} = useHelperLines<NodeType>(onNodesChange);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<CanvasShellBase<NodeType, EdgeType> {...props} onNodesChange={wrappedOnNodesChange}>
|
|
124
|
+
{children}
|
|
125
|
+
<HelperLines horizontal={helperLineHorizontal} vertical={helperLineVertical} />
|
|
126
|
+
</CanvasShellBase>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CanvasShell, type CanvasShellProps } from "./canvas-shell";
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import "@xyflow/react/dist/style.css";
|
|
3
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
4
|
+
import { useEdgesState, useNodesState } from "@xyflow/react";
|
|
5
|
+
import { CanvasShell } from "../canvas-shell";
|
|
6
|
+
import { FlowNode, type BrandFlowNode } from "../flow-node";
|
|
7
|
+
import { useFlowLayout } from "../flow-layout";
|
|
8
|
+
import { ZoomControls } from "../zoom-controls";
|
|
9
|
+
import { FlowButtonEdge, type BrandFlowButtonEdge } from "./flow-button-edge";
|
|
10
|
+
|
|
11
|
+
const nodeTypes = { brand: FlowNode };
|
|
12
|
+
const edgeTypes = { button: FlowButtonEdge };
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: "Flow/FlowButtonEdge",
|
|
16
|
+
component: FlowButtonEdge,
|
|
17
|
+
tags: ["autodocs"],
|
|
18
|
+
parameters: { layout: "fullscreen" },
|
|
19
|
+
} satisfies Meta<typeof FlowButtonEdge>;
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
|
|
23
|
+
/** Two nodes joined by a button edge — hover/focus the "+" to see it. */
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
render: () => {
|
|
26
|
+
const nodes: BrandFlowNode[] = [
|
|
27
|
+
{
|
|
28
|
+
id: "1",
|
|
29
|
+
type: "brand",
|
|
30
|
+
position: { x: 80, y: 40 },
|
|
31
|
+
data: { kind: "Source", title: "Ingest", tone: "accent" },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "2",
|
|
35
|
+
type: "brand",
|
|
36
|
+
position: { x: 80, y: 220 },
|
|
37
|
+
data: { kind: "Output", title: "Publish", tone: "success" },
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
const edges: BrandFlowButtonEdge[] = [
|
|
41
|
+
{ id: "e1-2", source: "1", target: "2", type: "button", data: {} },
|
|
42
|
+
];
|
|
43
|
+
return (
|
|
44
|
+
<div className="h-[350px]">
|
|
45
|
+
<CanvasShell nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Pattern 3 — Insert between: split an edge into two, with a node in the middle.
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const insertInitialNodes: BrandFlowNode[] = [
|
|
56
|
+
{
|
|
57
|
+
id: "ingest",
|
|
58
|
+
type: "brand",
|
|
59
|
+
position: { x: 60, y: 20 },
|
|
60
|
+
data: { kind: "Source", title: "Ingest", subtitle: "Raw data in", tone: "accent" },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "publish",
|
|
64
|
+
type: "brand",
|
|
65
|
+
position: { x: 60, y: 260 },
|
|
66
|
+
data: { kind: "Output", title: "Publish", subtitle: "Downstream sink", tone: "success" },
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
const insertInitialEdges: BrandFlowButtonEdge[] = [
|
|
70
|
+
{ id: "e-ingest-publish", source: "ingest", target: "publish", type: "button", data: {} },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Runs the auto-layout pass once the newly inserted node has been measured.
|
|
75
|
+
* Rendered as a CanvasShell child (invisible) so `useFlowLayout` has React
|
|
76
|
+
* Flow context; `pendingRef` is flipped by `insert()` in the parent.
|
|
77
|
+
*/
|
|
78
|
+
function AutoLayoutOnInsert({ pendingRef }: { pendingRef: React.MutableRefObject<boolean> }) {
|
|
79
|
+
const { layout, ready } = useFlowLayout();
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (pendingRef.current && ready) {
|
|
82
|
+
pendingRef.current = false;
|
|
83
|
+
layout("TB");
|
|
84
|
+
}
|
|
85
|
+
}, [ready, layout, pendingRef]);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Click a button edge's "+": a new `FlowNode` is inserted between its source
|
|
91
|
+
* and target, the original edge is replaced by two button edges (source→new,
|
|
92
|
+
* new→target), and the graph re-lays-out.
|
|
93
|
+
*/
|
|
94
|
+
function InsertBetweenDemo() {
|
|
95
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<BrandFlowNode>(insertInitialNodes);
|
|
96
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState<BrandFlowButtonEdge>(insertInitialEdges);
|
|
97
|
+
const stepRef = useRef(0);
|
|
98
|
+
const pendingLayoutRef = useRef(false);
|
|
99
|
+
|
|
100
|
+
const insert = useCallback(
|
|
101
|
+
(edgeId: string, source: string, target: string) => {
|
|
102
|
+
const step = ++stepRef.current;
|
|
103
|
+
const newNodeId = `inserted-${step}`;
|
|
104
|
+
|
|
105
|
+
setNodes((current) =>
|
|
106
|
+
current.concat({
|
|
107
|
+
id: newNodeId,
|
|
108
|
+
type: "brand",
|
|
109
|
+
// Temporary position — the auto-layout pass below repositions it.
|
|
110
|
+
position: { x: 0, y: 0 },
|
|
111
|
+
data: { kind: "Process", title: `Step ${step}` },
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
setEdges((current) => [
|
|
115
|
+
...current.filter((edge) => edge.id !== edgeId),
|
|
116
|
+
{ id: `e-${source}-${newNodeId}`, source, target: newNodeId, type: "button", data: {} },
|
|
117
|
+
{ id: `e-${newNodeId}-${target}`, source: newNodeId, target, type: "button", data: {} },
|
|
118
|
+
]);
|
|
119
|
+
pendingLayoutRef.current = true;
|
|
120
|
+
},
|
|
121
|
+
[setNodes, setEdges],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Attach `onInsert` at render time (not stored in state) so the handler
|
|
125
|
+
// always closes over the edge's own id/source/target.
|
|
126
|
+
const displayEdges = edges.map((edge) => ({
|
|
127
|
+
...edge,
|
|
128
|
+
data: { ...edge.data, onInsert: () => insert(edge.id, edge.source, edge.target) },
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="h-[520px]">
|
|
133
|
+
<CanvasShell
|
|
134
|
+
nodes={nodes}
|
|
135
|
+
edges={displayEdges}
|
|
136
|
+
nodeTypes={nodeTypes}
|
|
137
|
+
edgeTypes={edgeTypes}
|
|
138
|
+
onNodesChange={onNodesChange}
|
|
139
|
+
onEdgesChange={onEdgesChange}
|
|
140
|
+
>
|
|
141
|
+
<ZoomControls />
|
|
142
|
+
<AutoLayoutOnInsert pendingRef={pendingLayoutRef} />
|
|
143
|
+
</CanvasShell>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Click the "+" on the edge between "Ingest" and "Publish" to insert a step. */
|
|
149
|
+
export const InsertBetween: Story = {
|
|
150
|
+
render: () => <InsertBetweenDemo />,
|
|
151
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
BaseEdge: ({ id, path, style }: { id: string; path: string; style?: React.CSSProperties }) =>
|
|
13
|
+
React.createElement("svg", { "data-testid": "base-edge" }, [
|
|
14
|
+
React.createElement("path", { key: "p", d: path, id, style }),
|
|
15
|
+
]),
|
|
16
|
+
// Real EdgeLabelRenderer portals into a fixed container; a passthrough is
|
|
17
|
+
// enough here since we only assert the brand component's own output.
|
|
18
|
+
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => children,
|
|
19
|
+
getBezierPath: vi.fn(
|
|
20
|
+
({
|
|
21
|
+
sourceX,
|
|
22
|
+
sourceY,
|
|
23
|
+
targetX,
|
|
24
|
+
targetY,
|
|
25
|
+
}: {
|
|
26
|
+
sourceX: number;
|
|
27
|
+
sourceY: number;
|
|
28
|
+
targetX: number;
|
|
29
|
+
targetY: number;
|
|
30
|
+
}) => [
|
|
31
|
+
`M${sourceX},${sourceY} C${targetX},${targetY}`,
|
|
32
|
+
(sourceX + targetX) / 2,
|
|
33
|
+
(sourceY + targetY) / 2,
|
|
34
|
+
],
|
|
35
|
+
),
|
|
36
|
+
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
import { FlowButtonEdge, type BrandFlowButtonEdge } from "./flow-button-edge";
|
|
41
|
+
import type { EdgeProps } from "@xyflow/react";
|
|
42
|
+
|
|
43
|
+
afterEach(cleanup);
|
|
44
|
+
|
|
45
|
+
/** Minimal EdgeProps factory for FlowButtonEdge. */
|
|
46
|
+
function makeEdgeProps(
|
|
47
|
+
overrides: Partial<EdgeProps<BrandFlowButtonEdge>> = {},
|
|
48
|
+
): EdgeProps<BrandFlowButtonEdge> {
|
|
49
|
+
return {
|
|
50
|
+
id: "test-edge",
|
|
51
|
+
type: "button",
|
|
52
|
+
source: "node-a",
|
|
53
|
+
target: "node-b",
|
|
54
|
+
sourceX: 0,
|
|
55
|
+
sourceY: 0,
|
|
56
|
+
targetX: 100,
|
|
57
|
+
targetY: 100,
|
|
58
|
+
sourcePosition: "bottom" as EdgeProps["sourcePosition"],
|
|
59
|
+
targetPosition: "top" as EdgeProps["targetPosition"],
|
|
60
|
+
selected: false,
|
|
61
|
+
animated: false,
|
|
62
|
+
data: {},
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("FlowButtonEdge", () => {
|
|
68
|
+
it("renders a BaseEdge element", () => {
|
|
69
|
+
render(<FlowButtonEdge {...makeEdgeProps()} />);
|
|
70
|
+
expect(screen.getByTestId("base-edge")).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders the insert button with the default aria-label", () => {
|
|
74
|
+
render(<FlowButtonEdge {...makeEdgeProps()} />);
|
|
75
|
+
expect(screen.getByRole("button", { name: "Insert node on edge" })).toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders the insert button with a custom aria-label", () => {
|
|
79
|
+
render(<FlowButtonEdge {...makeEdgeProps({ data: { label: "Insert step" } })} />);
|
|
80
|
+
expect(screen.getByRole("button", { name: "Insert step" })).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("fires onInsert on click", async () => {
|
|
84
|
+
const user = userEvent.setup();
|
|
85
|
+
const onInsert = vi.fn();
|
|
86
|
+
render(<FlowButtonEdge {...makeEdgeProps({ data: { onInsert } })} />);
|
|
87
|
+
await user.click(screen.getByRole("button", { name: "Insert node on edge" }));
|
|
88
|
+
expect(onInsert).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("fires onInsert on keyboard Enter", async () => {
|
|
92
|
+
const user = userEvent.setup();
|
|
93
|
+
const onInsert = vi.fn();
|
|
94
|
+
render(<FlowButtonEdge {...makeEdgeProps({ data: { onInsert } })} />);
|
|
95
|
+
screen.getByRole("button", { name: "Insert node on edge" }).focus();
|
|
96
|
+
await user.keyboard("{Enter}");
|
|
97
|
+
expect(onInsert).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseEdge,
|
|
3
|
+
EdgeLabelRenderer,
|
|
4
|
+
getBezierPath,
|
|
5
|
+
type Edge,
|
|
6
|
+
type EdgeProps,
|
|
7
|
+
} from "@xyflow/react";
|
|
8
|
+
import { Plus } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
export interface FlowButtonEdgeData extends Record<string, unknown> {
|
|
11
|
+
/** aria-label for the insert button. @default "Insert node on edge" */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Called when the edge's "+" button is activated (click, Enter, or Space). */
|
|
14
|
+
onInsert?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type BrandFlowButtonEdge = Edge<FlowButtonEdgeData, "button">;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Branded bezier edge (matching `FlowEdge`'s `--flow-edge` token) with a
|
|
21
|
+
* centered "+" button rendered via `EdgeLabelRenderer` at the edge midpoint.
|
|
22
|
+
* Register it in `edgeTypes={{ button: FlowButtonEdge }}` and create edges
|
|
23
|
+
* with `type: "button"` and `data: FlowButtonEdgeData`.
|
|
24
|
+
*
|
|
25
|
+
* The button is a real `<button>` with an `aria-label`, keyboard-activatable
|
|
26
|
+
* via native Enter/Space. Fires `data.onInsert?.()` on activation — the
|
|
27
|
+
* typical handler splits the edge: insert a new node between source and
|
|
28
|
+
* target and rewire the two edges (see the "Insert between" story).
|
|
29
|
+
*/
|
|
30
|
+
export function FlowButtonEdge({
|
|
31
|
+
id,
|
|
32
|
+
sourceX,
|
|
33
|
+
sourceY,
|
|
34
|
+
targetX,
|
|
35
|
+
targetY,
|
|
36
|
+
sourcePosition,
|
|
37
|
+
targetPosition,
|
|
38
|
+
markerEnd,
|
|
39
|
+
style,
|
|
40
|
+
data,
|
|
41
|
+
}: EdgeProps<BrandFlowButtonEdge>) {
|
|
42
|
+
const [edgePath, labelX, labelY] = getBezierPath({
|
|
43
|
+
sourceX,
|
|
44
|
+
sourceY,
|
|
45
|
+
sourcePosition,
|
|
46
|
+
targetX,
|
|
47
|
+
targetY,
|
|
48
|
+
targetPosition,
|
|
49
|
+
});
|
|
50
|
+
const label = data?.label ?? "Insert node on edge";
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<BaseEdge
|
|
55
|
+
id={id}
|
|
56
|
+
path={edgePath}
|
|
57
|
+
markerEnd={markerEnd}
|
|
58
|
+
style={{ stroke: "var(--flow-edge)", strokeWidth: 1.5, ...style }}
|
|
59
|
+
/>
|
|
60
|
+
<EdgeLabelRenderer>
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
position: "absolute",
|
|
64
|
+
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
|
65
|
+
}}
|
|
66
|
+
className="nodrag nopan pointer-events-auto"
|
|
67
|
+
>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
aria-label={label}
|
|
71
|
+
onClick={() => data?.onInsert?.()}
|
|
72
|
+
className="flex size-5 items-center justify-center rounded-full border border-flow-group-border bg-flow-node text-flow-node-foreground shadow-sm transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
73
|
+
>
|
|
74
|
+
<Plus className="size-3" aria-hidden="true" />
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</EdgeLabelRenderer>
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { FlowEdge } from "./flow-edge";
|
|
7
|
+
|
|
8
|
+
const nodeTypes = { brand: FlowNode };
|
|
9
|
+
const edgeTypes = { brand: FlowEdge };
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: "Flow/FlowEdge",
|
|
13
|
+
component: FlowEdge,
|
|
14
|
+
tags: ["autodocs"],
|
|
15
|
+
parameters: { layout: "fullscreen" },
|
|
16
|
+
} satisfies Meta<typeof FlowEdge>;
|
|
17
|
+
export default meta;
|
|
18
|
+
type Story = StoryObj<typeof meta>;
|
|
19
|
+
|
|
20
|
+
/** Two nodes connected by a single branded bezier edge. */
|
|
21
|
+
export const Default: Story = {
|
|
22
|
+
render: () => {
|
|
23
|
+
const nodes: BrandFlowNode[] = [
|
|
24
|
+
{
|
|
25
|
+
id: "1",
|
|
26
|
+
type: "brand",
|
|
27
|
+
position: { x: 80, y: 40 },
|
|
28
|
+
data: { kind: "Source", title: "Postgres", tone: "accent" },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "2",
|
|
32
|
+
type: "brand",
|
|
33
|
+
position: { x: 80, y: 220 },
|
|
34
|
+
data: { kind: "Transform", title: "Clean & join", tone: "default" },
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
const edges: Edge[] = [{ id: "e1-2", source: "1", target: "2", type: "brand" }];
|
|
38
|
+
return (
|
|
39
|
+
<div className="h-[400px]">
|
|
40
|
+
<CanvasShell nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** A small pipeline with multiple branded edges. */
|
|
47
|
+
export const Pipeline: Story = {
|
|
48
|
+
render: () => {
|
|
49
|
+
const nodes: BrandFlowNode[] = [
|
|
50
|
+
{
|
|
51
|
+
id: "1",
|
|
52
|
+
type: "brand",
|
|
53
|
+
position: { x: 0, y: 0 },
|
|
54
|
+
data: { kind: "Source", title: "Postgres", tone: "accent" },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "2",
|
|
58
|
+
type: "brand",
|
|
59
|
+
position: { x: 220, y: 120 },
|
|
60
|
+
data: { kind: "Transform", title: "Clean & join", tone: "default" },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "3",
|
|
64
|
+
type: "brand",
|
|
65
|
+
position: { x: 440, y: 0 },
|
|
66
|
+
data: { kind: "Output", title: "Dashboard", tone: "success" },
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
const edges: Edge[] = [
|
|
70
|
+
{ id: "e1-2", source: "1", target: "2", type: "brand" },
|
|
71
|
+
{ id: "e2-3", source: "2", target: "3", type: "brand" },
|
|
72
|
+
];
|
|
73
|
+
return (
|
|
74
|
+
<div className="h-[400px]">
|
|
75
|
+
<CanvasShell nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { cleanup, render } 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
|
+
vi.mock("@xyflow/react", () => {
|
|
8
|
+
// 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
|
|
9
|
+
const React = require("react");
|
|
10
|
+
return {
|
|
11
|
+
BaseEdge: ({ id, path, style }: { id: string; path: string; style?: React.CSSProperties }) =>
|
|
12
|
+
React.createElement("svg", { "data-testid": "base-edge" }, [
|
|
13
|
+
React.createElement("path", { key: "p", d: path, id, style }),
|
|
14
|
+
]),
|
|
15
|
+
getBezierPath: vi.fn(
|
|
16
|
+
({
|
|
17
|
+
sourceX,
|
|
18
|
+
sourceY,
|
|
19
|
+
targetX,
|
|
20
|
+
targetY,
|
|
21
|
+
}: {
|
|
22
|
+
sourceX: number;
|
|
23
|
+
sourceY: number;
|
|
24
|
+
targetX: number;
|
|
25
|
+
targetY: number;
|
|
26
|
+
}) => [`M${sourceX},${sourceY} C${targetX},${targetY}`, 0, 0],
|
|
27
|
+
),
|
|
28
|
+
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
import { FlowEdge } from "./flow-edge";
|
|
33
|
+
import type { EdgeProps } from "@xyflow/react";
|
|
34
|
+
|
|
35
|
+
afterEach(cleanup);
|
|
36
|
+
|
|
37
|
+
/** Minimal EdgeProps factory for FlowEdge. */
|
|
38
|
+
function makeEdgeProps(overrides: Partial<EdgeProps> = {}): EdgeProps {
|
|
39
|
+
return {
|
|
40
|
+
id: "test-edge",
|
|
41
|
+
source: "node-a",
|
|
42
|
+
target: "node-b",
|
|
43
|
+
sourceX: 0,
|
|
44
|
+
sourceY: 0,
|
|
45
|
+
targetX: 100,
|
|
46
|
+
targetY: 100,
|
|
47
|
+
sourcePosition: "bottom" as EdgeProps["sourcePosition"],
|
|
48
|
+
targetPosition: "top" as EdgeProps["targetPosition"],
|
|
49
|
+
selected: false,
|
|
50
|
+
animated: false,
|
|
51
|
+
data: {},
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("FlowEdge", () => {
|
|
57
|
+
it("renders without throwing", () => {
|
|
58
|
+
const { container } = render(<FlowEdge {...makeEdgeProps()} />);
|
|
59
|
+
expect(container.firstChild).toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders a BaseEdge element", () => {
|
|
63
|
+
const { getByTestId } = render(<FlowEdge {...makeEdgeProps()} />);
|
|
64
|
+
expect(getByTestId("base-edge")).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("calls getBezierPath with the correct coordinates", async () => {
|
|
68
|
+
const { getBezierPath } = await import("@xyflow/react");
|
|
69
|
+
render(
|
|
70
|
+
<FlowEdge {...makeEdgeProps({ sourceX: 10, sourceY: 20, targetX: 110, targetY: 120 })} />,
|
|
71
|
+
);
|
|
72
|
+
expect(getBezierPath).toHaveBeenCalledWith(
|
|
73
|
+
expect.objectContaining({ sourceX: 10, sourceY: 20, targetX: 110, targetY: 120 }),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { BaseEdge, getBezierPath, type EdgeProps } from "@xyflow/react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Branded bezier edge using the `--flow-edge` token. Register it in
|
|
5
|
+
* `edgeTypes={{ brand: FlowEdge }}` and create edges with `type: "brand"`.
|
|
6
|
+
*/
|
|
7
|
+
export function FlowEdge({
|
|
8
|
+
id,
|
|
9
|
+
sourceX,
|
|
10
|
+
sourceY,
|
|
11
|
+
targetX,
|
|
12
|
+
targetY,
|
|
13
|
+
sourcePosition,
|
|
14
|
+
targetPosition,
|
|
15
|
+
markerEnd,
|
|
16
|
+
style,
|
|
17
|
+
}: EdgeProps) {
|
|
18
|
+
const [edgePath] = getBezierPath({
|
|
19
|
+
sourceX,
|
|
20
|
+
sourceY,
|
|
21
|
+
targetX,
|
|
22
|
+
targetY,
|
|
23
|
+
sourcePosition,
|
|
24
|
+
targetPosition,
|
|
25
|
+
});
|
|
26
|
+
return (
|
|
27
|
+
<BaseEdge
|
|
28
|
+
id={id}
|
|
29
|
+
path={edgePath}
|
|
30
|
+
markerEnd={markerEnd}
|
|
31
|
+
style={{ stroke: "var(--flow-edge)", strokeWidth: 1.5, ...style }}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FlowEdge } from "./flow-edge";
|