@elabs-ai/components-flow 4.0.0 → 4.2.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 (68) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +738 -20
  3. package/dist/index.js +985 -182
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -7
  6. package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
  7. package/src/__contract__/legend.contract.test.tsx +49 -0
  8. package/src/canvas-shell/canvas-shell.tsx +116 -1
  9. package/src/canvas-shell/use-measured-nodes.ts +101 -0
  10. package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
  11. package/src/flow-button-edge/flow-button-edge.tsx +8 -10
  12. package/src/flow-edge/flow-edge.stories.tsx +20 -0
  13. package/src/flow-edge/flow-edge.tsx +10 -3
  14. package/src/flow-edge-path/flow-edge-path.tsx +149 -0
  15. package/src/flow-edge-path/index.ts +1 -0
  16. package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
  17. package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
  18. package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
  19. package/src/flow-group-node/flow-group-node.tsx +24 -8
  20. package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
  21. package/src/flow-handle/flow-handle-anchor.ts +36 -0
  22. package/src/flow-handle/index.ts +1 -0
  23. package/src/flow-layout/flow-layout.stories.tsx +2 -2
  24. package/src/flow-layout/flow-layout.test.tsx +91 -0
  25. package/src/flow-layout/flow-layout.ts +77 -1
  26. package/src/flow-layout/layout-graph.test.ts +83 -2
  27. package/src/flow-layout/layout-graph.ts +23 -15
  28. package/src/flow-mini-map/flow-mini-map.stories.tsx +103 -0
  29. package/src/flow-node/flow-node.stories.tsx +151 -0
  30. package/src/flow-node/flow-node.tsx +56 -1
  31. package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
  32. package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
  33. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
  34. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
  35. package/src/flow-self-loop-edge/index.ts +13 -0
  36. package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
  37. package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
  38. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
  39. package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
  40. package/src/flow-smart-edge/index.ts +5 -1
  41. package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
  42. package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
  43. package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
  44. package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
  45. package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
  46. package/src/flow-weighted-edge/edge-aria.ts +117 -0
  47. package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
  48. package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
  49. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
  50. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
  51. package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
  52. package/src/flow-weighted-edge/index.ts +18 -0
  53. package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
  54. package/src/flow-weighted-edge/weight-scale.ts +86 -0
  55. package/src/index.ts +9 -0
  56. package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
  57. package/src/inspector-panel/inspector-panel.test.tsx +20 -0
  58. package/src/inspector-panel/inspector-panel.tsx +22 -9
  59. package/src/legend/index.ts +7 -1
  60. package/src/legend/legend.stories.tsx +126 -0
  61. package/src/legend/legend.test.tsx +180 -0
  62. package/src/legend/legend.tsx +222 -3
  63. package/src/templates-flow-workspace.stories.tsx +1 -1
  64. package/src/testing/canvas-framing.test.ts +107 -0
  65. package/src/testing/canvas-framing.ts +396 -0
  66. package/src/testing/edge-anchors.ts +107 -0
  67. package/src/testing/index.ts +36 -0
  68. 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.0.0",
3
+ "version": "4.2.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.0.0",
36
- "@elabs-ai/components-ui": "4.0.0"
35
+ "@elabs-ai/components-tokens": "4.2.0",
36
+ "@elabs-ai/components-ui": "4.2.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@testing-library/jest-dom": "^6.6.3",
@@ -52,10 +52,10 @@
52
52
  "typescript": "^5.7.3",
53
53
  "vitest": "^3.0.2",
54
54
  "@elabs-ai/components-eslint-config": "0.1.0",
55
- "@elabs-ai/components-tokens": "4.0.0",
56
- "@elabs-ai/components-ui": "4.0.0",
57
- "@elabs-ai/components-icons": "4.0.0",
58
- "@elabs-ai/components-typescript-config": "0.1.0"
55
+ "@elabs-ai/components-icons": "4.2.0",
56
+ "@elabs-ai/components-tokens": "4.2.0",
57
+ "@elabs-ai/components-typescript-config": "0.1.0",
58
+ "@elabs-ai/components-ui": "4.2.0"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsup",
@@ -0,0 +1,49 @@
1
+ // GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
2
+ /**
3
+ * Contract probe for InspectorPanel (`packages/flow/src/inspector-panel/inspector-panel.tsx`), derived from its
4
+ * `Default` story (packages/flow/src/inspector-panel/inspector-panel.stories.tsx). See scripts/gen-contract-tests.mjs.
5
+ */
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { render, cleanup } from "@testing-library/react";
8
+ import { createRef } from "react";
9
+ import * as stories from "../inspector-panel/inspector-panel.stories";
10
+ import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
11
+
12
+ afterEach(cleanup);
13
+
14
+ // Every component here has its own prop/ref/element shape; a generated probe
15
+ // stays generic on purpose (loosely typed, not untyped — see
16
+ // scripts/gen-contract-tests.mjs) rather than re-deriving each one.
17
+ const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
18
+ const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
19
+ const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
21
+ const Component = meta.component as any;
22
+ const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
23
+ const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
24
+
25
+ /** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
26
+ function contractIt(assertion: string, name: string, fn: () => void) {
27
+ const key = `flow-inspectorpanel--default|jsdom|jsdom|${assertion}`;
28
+ const reason = KNOWN_FAILURES[key];
29
+ if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
30
+ return it(name, fn);
31
+ }
32
+
33
+ describe("InspectorPanel contract", () => {
34
+ it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
35
+ const ref = createRef<Element>();
36
+ render(<Component {...args} ref={ref} />);
37
+ expect(ref.current).toBeInstanceOf(Element);
38
+ });
39
+
40
+ contractIt("className", "merges a caller className onto the root", () => {
41
+ const { container } = render(<Component {...args} className="contract-probe" />);
42
+ expect(container.querySelector(".contract-probe")).not.toBeNull();
43
+ });
44
+
45
+ contractIt("data-slot", 'exposes data-slot="inspector-panel" on its root', () => {
46
+ const { container } = render(<Component {...args} />);
47
+ expect(container.querySelector('[data-slot="inspector-panel"]')).not.toBeNull();
48
+ });
49
+ });
@@ -0,0 +1,49 @@
1
+ // GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
2
+ /**
3
+ * Contract probe for Legend (`packages/flow/src/legend/legend.tsx`), derived from its
4
+ * `Default` story (packages/flow/src/legend/legend.stories.tsx). See scripts/gen-contract-tests.mjs.
5
+ */
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { render, cleanup } from "@testing-library/react";
8
+ import { createRef } from "react";
9
+ import * as stories from "../legend/legend.stories";
10
+ import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
11
+
12
+ afterEach(cleanup);
13
+
14
+ // Every component here has its own prop/ref/element shape; a generated probe
15
+ // stays generic on purpose (loosely typed, not untyped — see
16
+ // scripts/gen-contract-tests.mjs) rather than re-deriving each one.
17
+ const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
18
+ const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
19
+ const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
21
+ const Component = meta.component as any;
22
+ const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
23
+ const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
24
+
25
+ /** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
26
+ function contractIt(assertion: string, name: string, fn: () => void) {
27
+ const key = `flow-legend--default|jsdom|jsdom|${assertion}`;
28
+ const reason = KNOWN_FAILURES[key];
29
+ if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
30
+ return it(name, fn);
31
+ }
32
+
33
+ describe("Legend contract", () => {
34
+ it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
35
+ const ref = createRef<Element>();
36
+ render(<Component {...args} ref={ref} />);
37
+ expect(ref.current).toBeInstanceOf(Element);
38
+ });
39
+
40
+ contractIt("className", "merges a caller className onto the root", () => {
41
+ const { container } = render(<Component {...args} className="contract-probe" />);
42
+ expect(container.querySelector(".contract-probe")).not.toBeNull();
43
+ });
44
+
45
+ contractIt("data-slot", 'exposes data-slot="legend" on its root', () => {
46
+ const { container } = render(<Component {...args} />);
47
+ expect(container.querySelector('[data-slot="legend"]')).not.toBeNull();
48
+ });
49
+ });
@@ -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
- <BaseEdge
49
+ <FlowEdgePath
55
50
  id={id}
56
51
  path={edgePath}
57
52
  markerEnd={markerEnd}
58
- style={{ stroke: "var(--flow-edge)", strokeWidth: 1.5, ...style }}
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-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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 { BaseEdge, getBezierPath, type EdgeProps } from "@xyflow/react";
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
- <BaseEdge
31
+ <FlowEdgePath
28
32
  id={id}
29
33
  path={edgePath}
30
34
  markerEnd={markerEnd}
31
- style={{ stroke: "var(--flow-edge)", strokeWidth: 1.5, ...style }}
35
+ data-slot="flow-edge"
36
+ stroke="var(--flow-edge)"
37
+ strokeWidth={1.5}
38
+ style={style}
32
39
  />
33
40
  );
34
41
  }