@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,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,5 @@
1
+ export {
2
+ FlowButtonEdge,
3
+ type FlowButtonEdgeData,
4
+ type BrandFlowButtonEdge,
5
+ } from "./flow-button-edge";
@@ -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";