@elabs-ai/components-flow 4.0.0 → 4.1.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/README.md +8 -8
- package/dist/index.d.ts +734 -20
- package/dist/index.js +976 -178
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/canvas-shell/canvas-shell.tsx +116 -1
- package/src/canvas-shell/use-measured-nodes.ts +101 -0
- package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
- package/src/flow-button-edge/flow-button-edge.tsx +8 -10
- package/src/flow-edge/flow-edge.stories.tsx +20 -0
- package/src/flow-edge/flow-edge.tsx +10 -3
- package/src/flow-edge-path/flow-edge-path.tsx +149 -0
- package/src/flow-edge-path/index.ts +1 -0
- package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
- package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
- package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
- package/src/flow-group-node/flow-group-node.tsx +24 -8
- package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
- package/src/flow-handle/flow-handle-anchor.ts +36 -0
- package/src/flow-handle/index.ts +1 -0
- package/src/flow-layout/flow-layout.stories.tsx +2 -2
- package/src/flow-layout/flow-layout.test.tsx +91 -0
- package/src/flow-layout/flow-layout.ts +77 -1
- package/src/flow-layout/layout-graph.test.ts +83 -2
- package/src/flow-layout/layout-graph.ts +23 -15
- package/src/flow-mini-map/flow-mini-map.stories.tsx +86 -0
- package/src/flow-node/flow-node.stories.tsx +151 -0
- package/src/flow-node/flow-node.tsx +56 -1
- package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
- package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
- package/src/flow-self-loop-edge/index.ts +13 -0
- package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
- package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
- package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
- package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
- package/src/flow-smart-edge/index.ts +5 -1
- package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
- package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
- package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
- package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
- package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
- package/src/flow-weighted-edge/edge-aria.ts +117 -0
- package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
- package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
- package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
- package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
- package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
- package/src/flow-weighted-edge/index.ts +18 -0
- package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
- package/src/flow-weighted-edge/weight-scale.ts +86 -0
- package/src/index.ts +9 -0
- package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
- package/src/inspector-panel/inspector-panel.test.tsx +20 -0
- package/src/inspector-panel/inspector-panel.tsx +13 -3
- package/src/legend/index.ts +7 -1
- package/src/legend/legend.stories.tsx +126 -0
- package/src/legend/legend.test.tsx +169 -0
- package/src/legend/legend.tsx +214 -3
- package/src/templates-flow-workspace.stories.tsx +1 -1
- package/src/testing/canvas-framing.test.ts +107 -0
- package/src/testing/canvas-framing.ts +396 -0
- package/src/testing/edge-anchors.ts +107 -0
- package/src/testing/index.ts +36 -0
- package/src/zoom-controls/zoom-controls.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elabs-ai/components-flow",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@xyflow/react": "^12.11.1",
|
|
33
33
|
"react": "^18.2.0 || ^19.0.0",
|
|
34
34
|
"react-dom": "^18.2.0 || ^19.0.0",
|
|
35
|
-
"@elabs-ai/components-tokens": "4.
|
|
36
|
-
"@elabs-ai/components-ui": "4.
|
|
35
|
+
"@elabs-ai/components-tokens": "4.1.0",
|
|
36
|
+
"@elabs-ai/components-ui": "4.1.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@testing-library/jest-dom": "^6.6.3",
|
|
@@ -51,10 +51,10 @@
|
|
|
51
51
|
"tsup": "^8.3.5",
|
|
52
52
|
"typescript": "^5.7.3",
|
|
53
53
|
"vitest": "^3.0.2",
|
|
54
|
+
"@elabs-ai/components-tokens": "4.1.0",
|
|
55
|
+
"@elabs-ai/components-icons": "4.1.0",
|
|
54
56
|
"@elabs-ai/components-eslint-config": "0.1.0",
|
|
55
|
-
"@elabs-ai/components-
|
|
56
|
-
"@elabs-ai/components-ui": "4.0.0",
|
|
57
|
-
"@elabs-ai/components-icons": "4.0.0",
|
|
57
|
+
"@elabs-ai/components-ui": "4.1.0",
|
|
58
58
|
"@elabs-ai/components-typescript-config": "0.1.0"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Background,
|
|
4
4
|
ReactFlow,
|
|
5
5
|
ReactFlowProvider,
|
|
6
|
+
useNodesInitialized,
|
|
7
|
+
useReactFlow,
|
|
8
|
+
useStoreApi,
|
|
6
9
|
type AriaLabelConfig,
|
|
7
10
|
type Edge,
|
|
11
|
+
type FitViewOptions,
|
|
8
12
|
type Node,
|
|
9
13
|
type ReactFlowProps,
|
|
10
14
|
} from "@xyflow/react";
|
|
11
15
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
12
16
|
import { HelperLines } from "../helper-lines/helper-lines";
|
|
13
17
|
import { useHelperLines } from "../helper-lines/use-helper-lines";
|
|
18
|
+
import { useMeasuredNodes } from "./use-measured-nodes";
|
|
14
19
|
|
|
15
20
|
export interface CanvasShellProps<
|
|
16
21
|
NodeType extends Node = Node,
|
|
@@ -27,6 +32,26 @@ export interface CanvasShellProps<
|
|
|
27
32
|
* use the exported `useHelperLines` + `<HelperLines>` directly.
|
|
28
33
|
*/
|
|
29
34
|
helperLines?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Re-fit the viewport whenever this value changes.
|
|
37
|
+
*
|
|
38
|
+
* React Flow's own `fitView` prop fits ONCE, as soon as the nodes are first measured,
|
|
39
|
+
* and then never again. A canvas whose positions arrive AFTER that first paint —
|
|
40
|
+
* anything laid out asynchronously, e.g. a debounced `layoutFlow` pass — therefore gets
|
|
41
|
+
* fitted to the pre-layout positions (typically every node still stacked at the origin)
|
|
42
|
+
* and stays there. Measured on the process map: the viewport pinned at React Flow's
|
|
43
|
+
* `maxZoom` of 2 with 2 of 11 nodes on screen, while the layout underneath was correct
|
|
44
|
+
* the whole time.
|
|
45
|
+
*
|
|
46
|
+
* Pass the key the layout is cached on (structure + direction), NOT something that
|
|
47
|
+
* changes on every render and not a metric: a re-fit is a viewport jump, so it must
|
|
48
|
+
* happen when the picture genuinely moved and never while the reader is reading. The
|
|
49
|
+
* fit waits for `useNodesInitialized`, so it measures real node boxes rather than
|
|
50
|
+
* fitting a degenerate bounding box a second time.
|
|
51
|
+
*/
|
|
52
|
+
fitViewKey?: string | number;
|
|
53
|
+
/** Options for the {@link fitViewKey} re-fit. Ignored without one. */
|
|
54
|
+
fitViewKeyOptions?: FitViewOptions;
|
|
30
55
|
/** Overlays rendered inside the flow (ZoomControls, Legend, Panels). */
|
|
31
56
|
children?: ReactNode;
|
|
32
57
|
className?: string;
|
|
@@ -91,23 +116,113 @@ function CanvasShellBase<NodeType extends Node, EdgeType extends Edge>({
|
|
|
91
116
|
children,
|
|
92
117
|
className,
|
|
93
118
|
ariaLabelConfig,
|
|
119
|
+
nodes,
|
|
120
|
+
onNodesChange,
|
|
121
|
+
fitViewKey,
|
|
122
|
+
fitViewKeyOptions,
|
|
94
123
|
...props
|
|
95
124
|
}: CanvasShellInnerProps<NodeType, EdgeType>) {
|
|
125
|
+
const measured = useMeasuredNodes<NodeType>(nodes, onNodesChange);
|
|
96
126
|
return (
|
|
97
127
|
<div className={cn("h-full w-full bg-canvas", className)}>
|
|
98
128
|
<ReactFlow
|
|
99
129
|
fitView
|
|
100
130
|
proOptions={{ hideAttribution: true }}
|
|
101
131
|
ariaLabelConfig={{ ...DEFAULT_ARIA_LABEL_CONFIG, ...ariaLabelConfig }}
|
|
132
|
+
nodes={measured.nodes}
|
|
133
|
+
onNodesChange={measured.onNodesChange}
|
|
102
134
|
{...props}
|
|
103
135
|
>
|
|
104
136
|
{background ? <Background gap={20} size={1} color="var(--canvas-grid)" /> : null}
|
|
137
|
+
{fitViewKey === undefined ? null : (
|
|
138
|
+
<FitViewOnKey fitViewKey={fitViewKey} options={fitViewKeyOptions} />
|
|
139
|
+
)}
|
|
105
140
|
{children}
|
|
106
141
|
</ReactFlow>
|
|
107
142
|
</div>
|
|
108
143
|
);
|
|
109
144
|
}
|
|
110
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Re-fits the viewport when `fitViewKey` changes and the nodes have been measured.
|
|
148
|
+
*
|
|
149
|
+
* Rendered as a CHILD of `<ReactFlow>` because `useReactFlow`/`useNodesInitialized` need
|
|
150
|
+
* the flow context, which only exists below it. It renders nothing.
|
|
151
|
+
*/
|
|
152
|
+
function FitViewOnKey({
|
|
153
|
+
fitViewKey,
|
|
154
|
+
options,
|
|
155
|
+
}: {
|
|
156
|
+
fitViewKey: string | number;
|
|
157
|
+
options?: FitViewOptions;
|
|
158
|
+
}) {
|
|
159
|
+
const { fitView, getNodes, getNodesBounds, setViewport } = useReactFlow();
|
|
160
|
+
const store = useStoreApi();
|
|
161
|
+
const nodesInitialized = useNodesInitialized();
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!nodesInitialized) return;
|
|
164
|
+
void fitView(options).then(() =>
|
|
165
|
+
anchorToStartWhenClamped(store, setViewport, getNodesBounds, getNodes(), options),
|
|
166
|
+
);
|
|
167
|
+
// `options` is deliberately absent: an inline object literal would re-fit on every
|
|
168
|
+
// render, which is a viewport jump under the reader's cursor. The key is the trigger.
|
|
169
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
170
|
+
}, [fitViewKey, nodesInitialized, fitView, getNodes, getNodesBounds, setViewport, store]);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* After a fit that could NOT show everything, show the BEGINNING rather than the middle.
|
|
176
|
+
*
|
|
177
|
+
* `fitView` always centres. That is right when the content fits — and wrong the moment a
|
|
178
|
+
* `minZoom` floor stops it from shrinking far enough, because centring a graph that
|
|
179
|
+
* overflows puts its first rank off the top of the pane and opens the canvas on the
|
|
180
|
+
* middle of a process nobody has read the start of yet. Every layout this shell is used
|
|
181
|
+
* with runs from an origin (top for `TB`, left for `LR`), so the content's own top-left
|
|
182
|
+
* corner IS the beginning, in both.
|
|
183
|
+
*
|
|
184
|
+
* Applied PER AXIS, and only to an axis that actually overflows: a tall graph in a wide
|
|
185
|
+
* pane is pinned to the top and stays horizontally centred, which is what a reader
|
|
186
|
+
* expects. A canvas whose content fits is left exactly as `fitView` left it.
|
|
187
|
+
*/
|
|
188
|
+
function anchorToStartWhenClamped(
|
|
189
|
+
store: ReturnType<typeof useStoreApi>,
|
|
190
|
+
setViewport: ReturnType<typeof useReactFlow>["setViewport"],
|
|
191
|
+
// The HOOK's `getNodesBounds`, never the top-level export: the standalone one warns in
|
|
192
|
+
// development when it is handed no `nodeLookup` ("Please use `getNodesBounds` from
|
|
193
|
+
// `useReactFlow`…") and measures a parent's own `position` instead of resolving a
|
|
194
|
+
// nested node's absolute one, so a keyed re-fit on a canvas with sub-flows anchors to
|
|
195
|
+
// the wrong corner.
|
|
196
|
+
getNodesBounds: ReturnType<typeof useReactFlow>["getNodesBounds"],
|
|
197
|
+
nodes: Node[],
|
|
198
|
+
options: FitViewOptions | undefined,
|
|
199
|
+
): void {
|
|
200
|
+
if (nodes.length === 0) return;
|
|
201
|
+
const { width, height, transform, panZoom } = store.getState();
|
|
202
|
+
const [x, y, zoom] = transform;
|
|
203
|
+
if (!width || !height || !zoom) return;
|
|
204
|
+
|
|
205
|
+
const bounds = getNodesBounds(nodes);
|
|
206
|
+
// The same fraction-of-the-pane padding `fitView` itself applies, so the anchored
|
|
207
|
+
// corner sits exactly where a fitted one would.
|
|
208
|
+
const padding = typeof options?.padding === "number" ? options.padding : 0.1;
|
|
209
|
+
const padX = width * padding;
|
|
210
|
+
const padY = height * padding;
|
|
211
|
+
|
|
212
|
+
const overflowsX = bounds.width * zoom > width - padX * 2 + 1;
|
|
213
|
+
const overflowsY = bounds.height * zoom > height - padY * 2 + 1;
|
|
214
|
+
if (!overflowsX && !overflowsY) return;
|
|
215
|
+
|
|
216
|
+
const next = {
|
|
217
|
+
x: overflowsX ? padX - bounds.x * zoom : x,
|
|
218
|
+
y: overflowsY ? padY - bounds.y * zoom : y,
|
|
219
|
+
zoom,
|
|
220
|
+
};
|
|
221
|
+
// `setViewport` is a no-op before the pan/zoom instance exists (the very first commit).
|
|
222
|
+
if (!panZoom) return;
|
|
223
|
+
void setViewport(next);
|
|
224
|
+
}
|
|
225
|
+
|
|
111
226
|
function CanvasShellWithHelperLines<NodeType extends Node, EdgeType extends Edge>({
|
|
112
227
|
onNodesChange,
|
|
113
228
|
children,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { Node, NodeChange, OnNodesChange } from "@xyflow/react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Feed React Flow's own measurements back onto the node objects a CONTROLLED canvas
|
|
6
|
+
* renders.
|
|
7
|
+
*
|
|
8
|
+
* React Flow measures every node in the DOM and keeps the result on its INTERNAL node
|
|
9
|
+
* record; the objects the consumer passed in `nodes` are never touched. That is fine for
|
|
10
|
+
* the renderer — edges and the viewport read the internal record — but three shipped
|
|
11
|
+
* surfaces read `node.measured` off the USER object instead, and each of them silently
|
|
12
|
+
* degrades when it is absent:
|
|
13
|
+
*
|
|
14
|
+
* - **`<MiniMap>`** bails out per node in `nodeHasDimensions(internals.userNode)` and
|
|
15
|
+
* renders NOTHING. Measured: a `CanvasShell` with 11 nodes drew 0 minimap rects while
|
|
16
|
+
* the minimap's own `viewBox` (computed from the internal records) was correct — a
|
|
17
|
+
* blank white panel, in the flow package's own `FlowMiniMap` story as much as in a
|
|
18
|
+
* composing package's.
|
|
19
|
+
* - **`layoutFlow`** falls back to `DEFAULT_NODE_WIDTH`/`DEFAULT_NODE_HEIGHT`
|
|
20
|
+
* (172×40) and hands dagre a node half the height of the real card, so ranks are laid
|
|
21
|
+
* out too close together and edge labels collide with the nodes below them.
|
|
22
|
+
* - Any consumer arithmetic over `node.measured` — the same trap, one layer out.
|
|
23
|
+
*
|
|
24
|
+
* React Flow's own examples never hit this because they drive the canvas with
|
|
25
|
+
* `useNodesState`, whose `onNodesChange` applies the `dimensions` change back into the
|
|
26
|
+
* array. A canvas driven from derived data (a discovered process graph, a layout hook)
|
|
27
|
+
* legitimately has no such setter, and gets a half-working canvas with no error. This
|
|
28
|
+
* hook closes that gap centrally so no consumer has to know about it: it remembers the
|
|
29
|
+
* dimensions React Flow reports and merges them into the nodes on the way past.
|
|
30
|
+
*
|
|
31
|
+
* It is deliberately a NO-OP in the two cases where React Flow already does the right
|
|
32
|
+
* thing: an UNCONTROLLED canvas (`defaultNodes`, where `hasDefaultNodes` makes React Flow
|
|
33
|
+
* apply the changes itself) never reaches here because `nodes` is undefined, and a
|
|
34
|
+
* controlled canvas whose consumer already applies dimension changes finds `measured`
|
|
35
|
+
* equal to the cached value and returns the very same array identity.
|
|
36
|
+
*/
|
|
37
|
+
export interface MeasuredNodesResult<NodeType extends Node> {
|
|
38
|
+
/** `nodes`, with React Flow's measured dimensions merged in. Same identity when nothing changed. */
|
|
39
|
+
nodes: NodeType[] | undefined;
|
|
40
|
+
/** The handler to give React Flow — records dimensions, then calls the consumer's own. */
|
|
41
|
+
onNodesChange: OnNodesChange<NodeType> | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useMeasuredNodes<NodeType extends Node>(
|
|
45
|
+
nodes: NodeType[] | undefined,
|
|
46
|
+
onNodesChange: OnNodesChange<NodeType> | undefined,
|
|
47
|
+
): MeasuredNodesResult<NodeType> {
|
|
48
|
+
const measured = useRef(new Map<string, { width: number; height: number }>());
|
|
49
|
+
// A version counter, not the map itself: the map is a ref so the change handler stays
|
|
50
|
+
// stable, and this is what tells the memo below that its contents moved.
|
|
51
|
+
const [version, setVersion] = useState(0);
|
|
52
|
+
|
|
53
|
+
const handleNodesChange = useCallback<OnNodesChange<NodeType>>(
|
|
54
|
+
(changes: NodeChange<NodeType>[]) => {
|
|
55
|
+
let touched = false;
|
|
56
|
+
for (const change of changes) {
|
|
57
|
+
if (change.type !== "dimensions" || !change.dimensions) continue;
|
|
58
|
+
const previous = measured.current.get(change.id);
|
|
59
|
+
if (
|
|
60
|
+
previous?.width === change.dimensions.width &&
|
|
61
|
+
previous.height === change.dimensions.height
|
|
62
|
+
) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
measured.current.set(change.id, { ...change.dimensions });
|
|
66
|
+
touched = true;
|
|
67
|
+
}
|
|
68
|
+
onNodesChange?.(changes);
|
|
69
|
+
if (touched) setVersion((n) => n + 1);
|
|
70
|
+
},
|
|
71
|
+
[onNodesChange],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const mergedNodes = useMemo(() => {
|
|
75
|
+
if (!nodes) return nodes;
|
|
76
|
+
// `version` is read so this recomputes when the ref's contents move; the map itself
|
|
77
|
+
// is intentionally not a dependency (a ref never changes identity).
|
|
78
|
+
void version;
|
|
79
|
+
let changed = false;
|
|
80
|
+
const next = nodes.map((node) => {
|
|
81
|
+
const dimensions = measured.current.get(node.id);
|
|
82
|
+
if (!dimensions) return node;
|
|
83
|
+
if (
|
|
84
|
+
node.measured?.width === dimensions.width &&
|
|
85
|
+
node.measured?.height === dimensions.height
|
|
86
|
+
) {
|
|
87
|
+
return node;
|
|
88
|
+
}
|
|
89
|
+
changed = true;
|
|
90
|
+
return { ...node, measured: dimensions };
|
|
91
|
+
});
|
|
92
|
+
return changed ? next : nodes;
|
|
93
|
+
}, [nodes, version]);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
nodes: mergedNodes,
|
|
97
|
+
// Only take the handler over when there is something to measure INTO. An uncontrolled
|
|
98
|
+
// canvas is left exactly as it was.
|
|
99
|
+
onNodesChange: nodes ? handleNodesChange : onNodesChange,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -2,10 +2,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
|
2
2
|
import "@xyflow/react/dist/style.css";
|
|
3
3
|
import { useCallback, useEffect, useRef } from "react";
|
|
4
4
|
import { useEdgesState, useNodesState } from "@xyflow/react";
|
|
5
|
+
import { expect, waitFor } from "storybook/test";
|
|
5
6
|
import { CanvasShell } from "../canvas-shell";
|
|
6
7
|
import { FlowNode, type BrandFlowNode } from "../flow-node";
|
|
7
8
|
import { useFlowLayout } from "../flow-layout";
|
|
8
9
|
import { ZoomControls } from "../zoom-controls";
|
|
10
|
+
import { edgePaths, endpointsOffHandles } from "../testing/edge-anchors";
|
|
9
11
|
import { FlowButtonEdge, type BrandFlowButtonEdge } from "./flow-button-edge";
|
|
10
12
|
|
|
11
13
|
const nodeTypes = { brand: FlowNode };
|
|
@@ -20,6 +22,14 @@ const meta = {
|
|
|
20
22
|
export default meta;
|
|
21
23
|
type Story = StoryObj<typeof meta>;
|
|
22
24
|
|
|
25
|
+
/** Every edge must terminate ON a handle dot — see `testing/edge-anchors`. */
|
|
26
|
+
async function expectAnchoredToHandles(canvasElement: HTMLElement, edgeCount: number) {
|
|
27
|
+
await waitFor(() => {
|
|
28
|
+
expect(edgePaths(canvasElement, "flow-button-edge")).toHaveLength(edgeCount);
|
|
29
|
+
expect(endpointsOffHandles(canvasElement, "flow-button-edge")).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/** Two nodes joined by a button edge — hover/focus the "+" to see it. */
|
|
24
34
|
export const Default: Story = {
|
|
25
35
|
render: () => {
|
|
@@ -46,6 +56,9 @@ export const Default: Story = {
|
|
|
46
56
|
</div>
|
|
47
57
|
);
|
|
48
58
|
},
|
|
59
|
+
play: async ({ canvasElement }) => {
|
|
60
|
+
await expectAnchoredToHandles(canvasElement, 1);
|
|
61
|
+
},
|
|
49
62
|
};
|
|
50
63
|
|
|
51
64
|
// ---------------------------------------------------------------------------
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BaseEdge,
|
|
3
|
-
EdgeLabelRenderer,
|
|
4
|
-
getBezierPath,
|
|
5
|
-
type Edge,
|
|
6
|
-
type EdgeProps,
|
|
7
|
-
} from "@xyflow/react";
|
|
1
|
+
import { EdgeLabelRenderer, getBezierPath, type Edge, type EdgeProps } from "@xyflow/react";
|
|
8
2
|
import { Plus } from "lucide-react";
|
|
3
|
+
import { FlowEdgePath } from "../flow-edge-path";
|
|
9
4
|
|
|
10
5
|
export interface FlowButtonEdgeData extends Record<string, unknown> {
|
|
11
6
|
/** aria-label for the insert button. @default "Insert node on edge" */
|
|
@@ -51,11 +46,14 @@ export function FlowButtonEdge({
|
|
|
51
46
|
|
|
52
47
|
return (
|
|
53
48
|
<>
|
|
54
|
-
<
|
|
49
|
+
<FlowEdgePath
|
|
55
50
|
id={id}
|
|
56
51
|
path={edgePath}
|
|
57
52
|
markerEnd={markerEnd}
|
|
58
|
-
|
|
53
|
+
data-slot="flow-button-edge"
|
|
54
|
+
stroke="var(--flow-edge)"
|
|
55
|
+
strokeWidth={1.5}
|
|
56
|
+
style={style}
|
|
59
57
|
/>
|
|
60
58
|
<EdgeLabelRenderer>
|
|
61
59
|
<div
|
|
@@ -69,7 +67,7 @@ export function FlowButtonEdge({
|
|
|
69
67
|
type="button"
|
|
70
68
|
aria-label={label}
|
|
71
69
|
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-
|
|
70
|
+
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-ring"
|
|
73
71
|
>
|
|
74
72
|
<Plus className="size-3" aria-hidden="true" />
|
|
75
73
|
</button>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import "@xyflow/react/dist/style.css";
|
|
3
3
|
import { type Edge } from "@xyflow/react";
|
|
4
|
+
import { expect, waitFor } from "storybook/test";
|
|
4
5
|
import { CanvasShell } from "../canvas-shell";
|
|
5
6
|
import { FlowNode, type BrandFlowNode } from "../flow-node";
|
|
7
|
+
import { edgePaths, endpointsOffHandles } from "../testing/edge-anchors";
|
|
6
8
|
import { FlowEdge } from "./flow-edge";
|
|
7
9
|
|
|
8
10
|
const nodeTypes = { brand: FlowNode };
|
|
@@ -17,6 +19,18 @@ const meta = {
|
|
|
17
19
|
export default meta;
|
|
18
20
|
type Story = StoryObj<typeof meta>;
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Every edge must terminate ON a handle dot — see `testing/edge-anchors`. React
|
|
24
|
+
* Flow's native anchors land on the dot's outer rim; the assertion allows
|
|
25
|
+
* anywhere on the dot, and fails on a line that meets a bare stretch of border.
|
|
26
|
+
*/
|
|
27
|
+
async function expectAnchoredToHandles(canvasElement: HTMLElement, edgeCount: number) {
|
|
28
|
+
await waitFor(() => {
|
|
29
|
+
expect(edgePaths(canvasElement, "flow-edge")).toHaveLength(edgeCount);
|
|
30
|
+
expect(endpointsOffHandles(canvasElement, "flow-edge")).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
/** Two nodes connected by a single branded bezier edge. */
|
|
21
35
|
export const Default: Story = {
|
|
22
36
|
render: () => {
|
|
@@ -41,6 +55,9 @@ export const Default: Story = {
|
|
|
41
55
|
</div>
|
|
42
56
|
);
|
|
43
57
|
},
|
|
58
|
+
play: async ({ canvasElement }) => {
|
|
59
|
+
await expectAnchoredToHandles(canvasElement, 1);
|
|
60
|
+
},
|
|
44
61
|
};
|
|
45
62
|
|
|
46
63
|
/** A small pipeline with multiple branded edges. */
|
|
@@ -76,4 +93,7 @@ export const Pipeline: Story = {
|
|
|
76
93
|
</div>
|
|
77
94
|
);
|
|
78
95
|
},
|
|
96
|
+
play: async ({ canvasElement }) => {
|
|
97
|
+
await expectAnchoredToHandles(canvasElement, 2);
|
|
98
|
+
},
|
|
79
99
|
};
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getBezierPath, type EdgeProps } from "@xyflow/react";
|
|
2
|
+
import { FlowEdgePath } from "../flow-edge-path";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Branded bezier edge using the `--flow-edge` token. Register it in
|
|
5
6
|
* `edgeTypes={{ brand: FlowEdge }}` and create edges with `type: "brand"`.
|
|
7
|
+
*
|
|
8
|
+
* Drawn through `FlowEdgePath`, so it carries the shared keyboard focus
|
|
9
|
+
* indicator (#286) — never reach for React Flow's `BaseEdge` directly.
|
|
6
10
|
*/
|
|
7
11
|
export function FlowEdge({
|
|
8
12
|
id,
|
|
@@ -24,11 +28,14 @@ export function FlowEdge({
|
|
|
24
28
|
targetPosition,
|
|
25
29
|
});
|
|
26
30
|
return (
|
|
27
|
-
<
|
|
31
|
+
<FlowEdgePath
|
|
28
32
|
id={id}
|
|
29
33
|
path={edgePath}
|
|
30
34
|
markerEnd={markerEnd}
|
|
31
|
-
|
|
35
|
+
data-slot="flow-edge"
|
|
36
|
+
stroke="var(--flow-edge)"
|
|
37
|
+
strokeWidth={1.5}
|
|
38
|
+
style={style}
|
|
32
39
|
/>
|
|
33
40
|
);
|
|
34
41
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { CSSProperties, SVGProps } from "react";
|
|
2
|
+
import { BaseEdge } from "@xyflow/react";
|
|
3
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extra stroke width, in px, of the `--ring` band drawn around a focused edge.
|
|
7
|
+
* The band sits *outside* the edge's own stroke, so it is visible whatever the
|
|
8
|
+
* edge's resting width is.
|
|
9
|
+
*/
|
|
10
|
+
export const FLOW_EDGE_FOCUS_RING_WIDTH = 3;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extra stroke width, in px, of the neutral contour drawn outside the `--ring`
|
|
14
|
+
* band. This is the layer that carries the WCAG 1.4.11 bar: `--foreground`
|
|
15
|
+
* measures 12.50:1 against `--canvas` in `light` and 16.25:1 in `dark`, where
|
|
16
|
+
* `--ring` alone measures 1.30:1 in `light` (issue #286).
|
|
17
|
+
*/
|
|
18
|
+
export const FLOW_EDGE_FOCUS_CONTOUR_WIDTH = 6;
|
|
19
|
+
|
|
20
|
+
export interface FlowEdgePathProps extends Omit<
|
|
21
|
+
SVGProps<SVGPathElement>,
|
|
22
|
+
"path" | "stroke" | "strokeWidth" | "style" | "ref"
|
|
23
|
+
> {
|
|
24
|
+
/** SVG path `d` for this edge, from `getBezierPath`/`getSmoothStepPath`/… */
|
|
25
|
+
path: string;
|
|
26
|
+
/** Resting stroke paint — a `var(--token)` reference or a resolved colour. */
|
|
27
|
+
stroke: string;
|
|
28
|
+
/** Resting stroke width in px. The focus layers are drawn wider than this. */
|
|
29
|
+
strokeWidth: number;
|
|
30
|
+
/** Dash pattern, applied to the edge AND to both focus layers so a dashed edge keeps its shape when focused. */
|
|
31
|
+
strokeDasharray?: string;
|
|
32
|
+
/** Stroke opacity for the edge itself. Never applied to the focus layers. */
|
|
33
|
+
strokeOpacity?: number;
|
|
34
|
+
/** React Flow marker url, e.g. from `EdgeProps.markerEnd`. */
|
|
35
|
+
markerEnd?: string;
|
|
36
|
+
/** React Flow marker url, e.g. from `EdgeProps.markerStart`. */
|
|
37
|
+
markerStart?: string;
|
|
38
|
+
/** Width of the invisible pointer-target path React Flow draws over the edge. */
|
|
39
|
+
interactionWidth?: number;
|
|
40
|
+
/** Merged onto the edge path, last — so a consumer's `style.stroke` still wins. */
|
|
41
|
+
style?: CSSProperties;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The edge path every brand edge type draws, with the keyboard focus indicator
|
|
46
|
+
* built in. **Use this instead of React Flow's `BaseEdge`** — a custom edge that
|
|
47
|
+
* reaches for `BaseEdge` directly ships with no focus indicator at all, which is
|
|
48
|
+
* the defect issue #286 records (and `no-raw-base-edge.test.ts` fails on).
|
|
49
|
+
*
|
|
50
|
+
* ## Why the indicator is drawn rather than restyled
|
|
51
|
+
*
|
|
52
|
+
* React Flow zeroes the native outline on a focused edge
|
|
53
|
+
* (`.react-flow__edge:focus-visible { outline: none }`) and substitutes a stroke
|
|
54
|
+
* recolour on `.react-flow__edge-path`. Every brand edge passes its stroke as an
|
|
55
|
+
* **inline style** on that exact path — `BaseEdge` spreads `style` onto it — and
|
|
56
|
+
* an inline declaration beats any stylesheet rule, so the substitute never
|
|
57
|
+
* painted. Focusing an edge changed nothing on screen: WCAG 2.4.7 failed on
|
|
58
|
+
* every edge of every canvas, in both themes.
|
|
59
|
+
*
|
|
60
|
+
* The obvious repair — move the stroke into a custom property so a stylesheet
|
|
61
|
+
* rule can reach it — does **not** work here, and that was measured rather than
|
|
62
|
+
* assumed: React Flow's own `.react-flow__edge-path { stroke: … }` ships
|
|
63
|
+
* **unlayered**, and unlayered CSS outranks anything in `@layer utilities`,
|
|
64
|
+
* where Tailwind puts every utility. A `stroke-[var(--flow-edge-stroke)]` class
|
|
65
|
+
* would therefore lose to React Flow's `#b1b1b7` default and repaint every edge
|
|
66
|
+
* in the library. `!important` and setting `--xy-edge-stroke-selected` were both
|
|
67
|
+
* rejected in the issue.
|
|
68
|
+
*
|
|
69
|
+
* So the indicator is drawn on two paths this component owns, under the edge and
|
|
70
|
+
* on the same geometry. Nothing can shadow them: not React Flow's stylesheet,
|
|
71
|
+
* not a consumer's inline `style.stroke`, not a future edge type's own painting.
|
|
72
|
+
* They are hidden (`opacity-0`) until the ancestor `g.react-flow__edge` matches
|
|
73
|
+
* `:focus-visible`, which is a plain descendant selector on classes only this
|
|
74
|
+
* component uses.
|
|
75
|
+
*
|
|
76
|
+
* ## Compound, because one colour is not enough
|
|
77
|
+
*
|
|
78
|
+
* The indicator is two layers, per the compound-indicator recipe in
|
|
79
|
+
* `.claude/rules/theming.md`: a neutral `--foreground` contour at
|
|
80
|
+
* `strokeWidth + 6`, and the `--ring` band at `strokeWidth + 3` over it. The
|
|
81
|
+
* contour is the layer that clears WCAG 1.4.11's 3:1 bar against `--canvas` in
|
|
82
|
+
* both reference themes (12.50:1 `light`, 16.25:1 `dark`); a bare `--ring`
|
|
83
|
+
* stroke would not (1.30:1 in `light`). It is opacity + stroke only — no
|
|
84
|
+
* shadow — so it survives `data-decoration="8|9|10"`, which goes shadowless.
|
|
85
|
+
*/
|
|
86
|
+
export function FlowEdgePath({
|
|
87
|
+
path,
|
|
88
|
+
stroke,
|
|
89
|
+
strokeWidth,
|
|
90
|
+
strokeDasharray,
|
|
91
|
+
strokeOpacity,
|
|
92
|
+
markerEnd,
|
|
93
|
+
markerStart,
|
|
94
|
+
interactionWidth,
|
|
95
|
+
className,
|
|
96
|
+
style,
|
|
97
|
+
...props
|
|
98
|
+
}: FlowEdgePathProps) {
|
|
99
|
+
// The focus layers repeat the edge's own dash pattern so a dashed edge (the
|
|
100
|
+
// `"back"` variant) does not gain a solid halo. `"none"` is explicit rather
|
|
101
|
+
// than omitted because React Flow's `.react-flow__edge.animated path` rule
|
|
102
|
+
// sets a dasharray on *every* path inside an animated edge, ours included.
|
|
103
|
+
const focusDash = strokeDasharray ?? "none";
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
{/* Outer neutral contour — the layer that carries the 3:1 bar. */}
|
|
108
|
+
<path
|
|
109
|
+
d={path}
|
|
110
|
+
fill="none"
|
|
111
|
+
aria-hidden="true"
|
|
112
|
+
data-slot="flow-edge-focus-contour"
|
|
113
|
+
className="pointer-events-none stroke-foreground opacity-0 [.react-flow\_\_edge:focus-visible_&]:opacity-100"
|
|
114
|
+
strokeWidth={strokeWidth + FLOW_EDGE_FOCUS_CONTOUR_WIDTH}
|
|
115
|
+
strokeDasharray={focusDash}
|
|
116
|
+
strokeLinecap="round"
|
|
117
|
+
/>
|
|
118
|
+
{/* Inner --ring band, drawn over the contour and under the edge. */}
|
|
119
|
+
<path
|
|
120
|
+
d={path}
|
|
121
|
+
fill="none"
|
|
122
|
+
aria-hidden="true"
|
|
123
|
+
data-slot="flow-edge-focus-ring"
|
|
124
|
+
className="pointer-events-none stroke-ring opacity-0 [.react-flow\_\_edge:focus-visible_&]:opacity-100"
|
|
125
|
+
strokeWidth={strokeWidth + FLOW_EDGE_FOCUS_RING_WIDTH}
|
|
126
|
+
strokeDasharray={focusDash}
|
|
127
|
+
strokeLinecap="round"
|
|
128
|
+
/>
|
|
129
|
+
<BaseEdge
|
|
130
|
+
{...props}
|
|
131
|
+
path={path}
|
|
132
|
+
markerEnd={markerEnd}
|
|
133
|
+
markerStart={markerStart}
|
|
134
|
+
interactionWidth={interactionWidth}
|
|
135
|
+
className={cn(
|
|
136
|
+
"transition-[stroke,stroke-width] duration-fast ease-standard motion-reduce:transition-none",
|
|
137
|
+
className,
|
|
138
|
+
)}
|
|
139
|
+
style={{
|
|
140
|
+
stroke,
|
|
141
|
+
strokeWidth,
|
|
142
|
+
...(strokeDasharray ? { strokeDasharray } : null),
|
|
143
|
+
...(strokeOpacity !== undefined ? { strokeOpacity } : null),
|
|
144
|
+
...style,
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./flow-edge-path";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #286 tripwire. React Flow's `BaseEdge` ships with a focus indicator that any
|
|
7
|
+
* inline `stroke` silently disables — which is how every brand edge in this
|
|
8
|
+
* package came to have no keyboard focus indicator at all. `FlowEdgePath` is
|
|
9
|
+
* the repair, and this test is what stops the next edge type from reaching past
|
|
10
|
+
* it: a custom edge that imports `BaseEdge` directly is, by construction, an
|
|
11
|
+
* edge with no focus indicator.
|
|
12
|
+
*
|
|
13
|
+
* A component that genuinely needs the primitive can extend `FlowEdgePath`
|
|
14
|
+
* instead — that is the one place the import is allowed.
|
|
15
|
+
*/
|
|
16
|
+
// The jsdom environment gives `import.meta.url` an http: scheme, so resolve
|
|
17
|
+
// from the Vitest root (this package) instead.
|
|
18
|
+
const SRC = join(process.cwd(), "src");
|
|
19
|
+
const ALLOWED = new Set(["flow-edge-path/flow-edge-path.tsx"]);
|
|
20
|
+
|
|
21
|
+
function walk(dir: string, prefix = ""): string[] {
|
|
22
|
+
return readdirSync(dir).flatMap((entry) => {
|
|
23
|
+
const abs = join(dir, entry);
|
|
24
|
+
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
25
|
+
if (statSync(abs).isDirectory()) return walk(abs, rel);
|
|
26
|
+
if (!/\.tsx?$/.test(entry) || /\.(test|stories)\.tsx?$/.test(entry)) return [];
|
|
27
|
+
return [rel];
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("no edge type reaches past FlowEdgePath (#286)", () => {
|
|
32
|
+
it("imports BaseEdge from @xyflow/react in exactly one shipped module", () => {
|
|
33
|
+
const offenders = walk(SRC).filter((rel) => {
|
|
34
|
+
if (ALLOWED.has(rel)) return false;
|
|
35
|
+
const source = readFileSync(join(SRC, rel), "utf8");
|
|
36
|
+
// Only an IMPORT counts — prose mentioning the name in a doc comment does not.
|
|
37
|
+
return /import\s+[^;]*\bBaseEdge\b[^;]*from\s+["']@xyflow\/react["']/s.test(source);
|
|
38
|
+
});
|
|
39
|
+
expect(
|
|
40
|
+
offenders,
|
|
41
|
+
`These modules import React Flow's BaseEdge directly, so their edges have no keyboard ` +
|
|
42
|
+
`focus indicator (#286). Draw through FlowEdgePath instead: ${offenders.join(", ")}`,
|
|
43
|
+
).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getBezierPath, useInternalNode, type EdgeProps } from "@xyflow/react";
|
|
2
|
+
import { FlowEdgePath } from "../flow-edge-path";
|
|
2
3
|
import { getEdgeParams } from "./floating-edge-geometry";
|
|
3
4
|
|
|
4
5
|
/** Optional per-edge `data` for {@link FlowFloatingEdge}. */
|
|
@@ -45,11 +46,14 @@ export function FlowFloatingEdge({ id, source, target, markerEnd, style, data }:
|
|
|
45
46
|
|
|
46
47
|
return (
|
|
47
48
|
<>
|
|
48
|
-
<
|
|
49
|
+
<FlowEdgePath
|
|
49
50
|
id={id}
|
|
50
51
|
path={edgePath}
|
|
51
52
|
markerEnd={markerEnd}
|
|
52
|
-
|
|
53
|
+
data-slot="flow-floating-edge"
|
|
54
|
+
stroke="var(--flow-edge)"
|
|
55
|
+
strokeWidth={1.5}
|
|
56
|
+
style={style}
|
|
53
57
|
/>
|
|
54
58
|
{showAnchors ? (
|
|
55
59
|
<g
|