@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
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
2
2
  import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
3
3
  import { Star, type LucideIcon } from "lucide-react";
4
4
  import { STATUS_TONE_ICONS } from "@elabs-ai/components-ui";
5
+ import { FLOW_HANDLE_ANCHOR_CLASS } from "../flow-handle/flow-handle-anchor";
5
6
  import { cn } from "@elabs-ai/components-ui/lib/cn";
6
7
 
7
8
  /** A node side that can carry a handle. Doubles as the handle's stable id. */
@@ -45,6 +46,24 @@ export interface FlowNodeData extends Record<string, unknown> {
45
46
  * bottom-source (unchanged, backward-compatible).
46
47
  */
47
48
  handles?: FlowNodeHandles;
49
+ /**
50
+ * An extra row rendered INSIDE the card, below the text block — a meter bar, a
51
+ * sparkline, a chip row.
52
+ *
53
+ * It exists because content a composing package renders BESIDE `FlowNode` (as a sibling
54
+ * inside React Flow's node element) silently breaks the canvas's geometry: React Flow
55
+ * positions every `<Handle>` against the nearest positioned ancestor and measures the
56
+ * node box from its own wrapper, so a sibling row makes the node box taller than the
57
+ * visible card and the handles drift off the card's border by exactly that difference.
58
+ * Measured on the process map's activity node, whose 6px meter and 4px gap put every
59
+ * bottom dot 10px below the card it was supposed to sit on, and every left/right dot
60
+ * 5px below the card's own mid-line.
61
+ *
62
+ * Put the row here instead and the card IS the node box again, so the dots land on the
63
+ * card edge for free. Nothing is rendered when it is absent — existing nodes are
64
+ * byte-identical.
65
+ */
66
+ footer?: ReactNode;
48
67
  }
49
68
 
50
69
  export type BrandFlowNode = Node<FlowNodeData, "brand">;
@@ -101,11 +120,39 @@ const sidePosition: Record<FlowHandleSide, Position> = {
101
120
  left: Position.Left,
102
121
  };
103
122
 
104
- const handleClassName = "!size-2 !border-2 !border-flow-edge !bg-flow-node";
123
+ // `FLOW_HANDLE_ANCHOR_CLASS` last: a connector dot must never be in flight when React
124
+ // Flow measures it. See `flow-handle/flow-handle-anchor.ts`.
125
+ const handleClassName = `!size-2 !border-2 !border-flow-edge !bg-flow-node ${FLOW_HANDLE_ANCHOR_CLASS}`;
105
126
 
106
127
  /**
107
128
  * Branded custom node. Register it in `nodeTypes={{ brand: FlowNode }}` and
108
129
  * create nodes with `type: "brand"` and `data: FlowNodeData`.
130
+ *
131
+ * ## Focus vs selection (#312)
132
+ *
133
+ * `selected && "ring-2 ring-ring"` below is a SELECTION marker, not a focus
134
+ * indicator — it is React Flow's own click-driven `selected` state and is the
135
+ * genuine-selection carve-out `.claude/rules/theming.md` names explicitly.
136
+ * Keyboard focus is a separate, independent signal this component used to omit
137
+ * entirely (issue #312): React Flow puts `tabIndex`/`role="group"` and the real
138
+ * `:focus-visible` state on **its own wrapper** (`.react-flow__node`, which
139
+ * also always carries a `data-id` attribute), one level ABOVE the `<div>` this
140
+ * component returns — so neither `focus-ring` (`:focus-visible` on self) nor
141
+ * `focus-ring-within` (`:focus-within`, a focused descendant) can ever fire
142
+ * here; focus is PROXIED to an ancestor this component doesn't render.
143
+ * `focus-ring-static` is the flavour built for that exact shape (ADR 0027),
144
+ * gated by an ancestor-selector arbitrary variant — the same idiom
145
+ * `FlowEdgePath` uses for `.react-flow__edge:focus-visible`, keyed on
146
+ * `[data-id]` here (rather than the escaped `.react-flow\_\_node` class) so
147
+ * the selector needs no backslash escaping inside a plain JS string — a
148
+ * literal `\_` in a `cn()` argument is a real JS string escape and would be
149
+ * silently stripped at runtime (unlike in a bare, unbraced JSX attribute,
150
+ * where backslashes are never processed — the reason `FlowEdgePath` can use
151
+ * the class form safely and this component, composing through `cn()`, cannot).
152
+ * The two signals compose without merging into one ring: `selected` alone
153
+ * paints the ring layer only, while a focused node additionally gets the
154
+ * `--ring-contour` outline drawn outside it, so "selected AND focused" reads
155
+ * as two visible layers, not the single ring "selected alone" paints.
109
156
  */
110
157
  export function FlowNode({
111
158
  data,
@@ -117,11 +164,18 @@ export function FlowNode({
117
164
  const ToneIcon = toneIcon[tone];
118
165
  return (
119
166
  <div
167
+ // The PAINTED card, and the box every handle dot must sit on the border of.
168
+ // `.react-flow__node` (the wrapper React Flow positions) can legitimately be
169
+ // taller than this — a composing package may render a badge or a meter beside
170
+ // the card — so a test that wants "is the connector on the card?" measures
171
+ // against this slot, never against the wrapper. See `testing/canvas-framing`.
172
+ data-slot="flow-node"
120
173
  data-tone={tone}
121
174
  className={cn(
122
175
  "min-w-44 rounded-lg border bg-flow-node px-3 py-2 text-flow-node-foreground shadow-sm transition-[box-shadow,border-color] duration-fast ease-standard",
123
176
  toneRing[tone],
124
177
  selected && "ring-2 ring-ring",
178
+ "[[data-id]:focus-visible_&]:focus-ring-static",
125
179
  )}
126
180
  >
127
181
  {data.handles ? (
@@ -184,6 +238,7 @@ export function FlowNode({
184
238
  <ToneIcon aria-hidden="true" className={cn("size-3.5 shrink-0", toneIconColor[tone])} />
185
239
  ) : null}
186
240
  </div>
241
+ {data.footer ? <div className="mt-2">{data.footer}</div> : null}
187
242
  {toneLabel[tone] ? <span className="sr-only">{toneLabel[tone]}</span> : null}
188
243
  </div>
189
244
  );
@@ -1,5 +1,6 @@
1
1
  import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
2
2
  import { Plus } from "lucide-react";
3
+ import { FLOW_HANDLE_ANCHOR_CLASS } from "../flow-handle/flow-handle-anchor";
3
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
4
5
 
5
6
  export interface FlowPlaceholderNodeData extends Record<string, unknown> {
@@ -29,7 +30,9 @@ export function FlowPlaceholderNode({ data }: NodeProps<BrandFlowPlaceholderNode
29
30
  <Handle
30
31
  type="target"
31
32
  position={Position.Top}
32
- className="!size-2 !border-2 !border-flow-edge !bg-flow-node"
33
+ // `FLOW_HANDLE_ANCHOR_CLASS`: a connector dot must never be in flight when
34
+ // React Flow measures it. See `flow-handle/flow-handle-anchor.ts`.
35
+ className={`!size-2 !border-2 !border-flow-edge !bg-flow-node ${FLOW_HANDLE_ANCHOR_CLASS}`}
33
36
  />
34
37
  <button
35
38
  type="button"
@@ -38,7 +41,7 @@ export function FlowPlaceholderNode({ data }: NodeProps<BrandFlowPlaceholderNode
38
41
  className={cn(
39
42
  "flex min-w-44 items-center justify-center gap-1.5 rounded-lg border border-dashed border-flow-group-border bg-flow-group px-3 py-2 text-muted-foreground",
40
43
  "transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground",
41
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
44
+ "focus-ring",
42
45
  )}
43
46
  >
44
47
  <Plus className="size-4" aria-hidden="true" />
@@ -0,0 +1,275 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import "@xyflow/react/dist/style.css";
3
+ import { expect, userEvent, waitFor, within } from "storybook/test";
4
+ import { CanvasShell } from "../canvas-shell";
5
+ import { FlowNode, type BrandFlowNode } from "../flow-node";
6
+ import { layoutFlow } from "../flow-layout";
7
+ import { FlowWeightedEdge, type BrandFlowWeightedEdge } from "../flow-weighted-edge";
8
+ import { FlowSelfLoopEdge, type BrandFlowSelfLoopEdge } from "./flow-self-loop-edge";
9
+
10
+ const nodeTypes = { brand: FlowNode };
11
+ const edgeTypes = { weighted: FlowWeightedEdge, "self-loop": FlowSelfLoopEdge };
12
+
13
+ type ProcessEdge = BrandFlowWeightedEdge | BrandFlowSelfLoopEdge;
14
+
15
+ const meta = {
16
+ title: "Flow/FlowSelfLoopEdge",
17
+ component: FlowSelfLoopEdge,
18
+ tags: ["autodocs"],
19
+ parameters: {
20
+ layout: "fullscreen",
21
+ docs: {
22
+ description: {
23
+ component:
24
+ "A branded edge for the case where source and target are the same node — the " +
25
+ "this-step-repeated signal in a process map — drawn as a closed arc above the node " +
26
+ "instead of a straight or curved line between two points, since dagre cannot lay a " +
27
+ "self-loop out on its own. It shares its weight-to-stroke-width scale with " +
28
+ "`FlowWeightedEdge`, so reach for this one only for a genuine self-loop and the " +
29
+ "weighted edge for everything else; see " +
30
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
31
+ },
32
+ },
33
+ },
34
+ } satisfies Meta<typeof FlowSelfLoopEdge>;
35
+ export default meta;
36
+ type Story = StoryObj<typeof meta>;
37
+
38
+ /** Three steps of an order process, stacked top-to-bottom. */
39
+ function processNodes(): BrandFlowNode[] {
40
+ return [
41
+ {
42
+ id: "a",
43
+ type: "brand",
44
+ position: { x: 0, y: 0 },
45
+ data: { kind: "Start", title: "Received" },
46
+ },
47
+ {
48
+ id: "b",
49
+ type: "brand",
50
+ position: { x: 0, y: 180 },
51
+ data: { kind: "Step", title: "Review" },
52
+ },
53
+ {
54
+ id: "c",
55
+ type: "brand",
56
+ position: { x: 0, y: 360 },
57
+ data: { kind: "End", title: "Approved" },
58
+ },
59
+ ];
60
+ }
61
+
62
+ /**
63
+ * Every geometry read in these play functions sits inside `waitFor`/`findBy*`:
64
+ * React Flow arrives in a lazy chunk and paints its edges only after it has
65
+ * measured node handles, so a bare `querySelector` + expect races the paint.
66
+ */
67
+ const loopPaths = (root: HTMLElement) =>
68
+ Array.from(root.querySelectorAll<SVGPathElement>('[data-slot="flow-self-loop-edge"]'));
69
+ const weightedPaths = (root: HTMLElement) =>
70
+ Array.from(root.querySelectorAll<SVGPathElement>('[data-slot="flow-weighted-edge"]'));
71
+ /** Browsers serialise `stroke-dasharray` as "6, 4"; jsdom keeps "6 4". Normalise both. */
72
+ const dashPattern = (path: SVGPathElement) =>
73
+ path.style.strokeDasharray.replace(/,/g, " ").replace(/\s+/g, " ").trim();
74
+
75
+ /** A step that repeats: the arc leaves the node's top-right and re-enters top-left. Tab to reach its label. */
76
+ export const SelfLoop: Story = {
77
+ render: () => {
78
+ const edges: ProcessEdge[] = [
79
+ { id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 9 } },
80
+ { id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 5 } },
81
+ {
82
+ id: "e-bb",
83
+ source: "b",
84
+ target: "b",
85
+ type: "self-loop",
86
+ data: { weight: 3, label: "12×", secondaryLabel: "2.1d avg" },
87
+ },
88
+ ];
89
+ return (
90
+ <div className="h-[520px]">
91
+ <CanvasShell
92
+ nodes={processNodes()}
93
+ edges={edges}
94
+ nodeTypes={nodeTypes}
95
+ edgeTypes={edgeTypes}
96
+ />
97
+ </div>
98
+ );
99
+ },
100
+ play: async ({ canvasElement }) => {
101
+ const canvas = within(canvasElement);
102
+
103
+ // The loop is a SHAPE, not a colour: it ends left of where it started and
104
+ // rises above the node's top edge, which no forward edge does.
105
+ await waitFor(() => {
106
+ const [loop] = loopPaths(canvasElement);
107
+ expect(loop).toBeDefined();
108
+ expect(loop!.getAttribute("d")).toMatch(/^M [\d.-]+,[\d.-]+ C .+$/);
109
+ });
110
+
111
+ // …and the meaning also reaches assistive tech as real text.
112
+ await canvas.findByRole("img", { name: "Self-loop on Review — this step repeats" });
113
+
114
+ // The loop's label is a genuine keyboard tab stop, reached by tabbing —
115
+ // not by a synthetic .focus() call.
116
+ const pill = await canvas.findByRole("button", { name: "12× · 2.1d avg" });
117
+ let guard = 0;
118
+ while (document.activeElement !== pill && guard < 60) {
119
+ await userEvent.tab();
120
+ guard += 1;
121
+ }
122
+ await expect(pill).toHaveFocus();
123
+ },
124
+ };
125
+
126
+ /** A rework edge running against the flow: dashed, and routed clear of the forward edge it doubles back over. */
127
+ export const BackEdge: Story = {
128
+ render: () => {
129
+ const edges: ProcessEdge[] = [
130
+ { id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 9 } },
131
+ { id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 6 } },
132
+ {
133
+ id: "e-cb",
134
+ source: "c",
135
+ target: "b",
136
+ type: "weighted",
137
+ data: { weight: 2, variant: "back", label: "18× reworked" },
138
+ },
139
+ ];
140
+ return (
141
+ <div className="h-[520px]">
142
+ <CanvasShell
143
+ nodes={processNodes()}
144
+ edges={edges}
145
+ nodeTypes={nodeTypes}
146
+ edgeTypes={edgeTypes}
147
+ />
148
+ </div>
149
+ );
150
+ },
151
+ play: async ({ canvasElement }) => {
152
+ const canvas = within(canvasElement);
153
+
154
+ await waitFor(() => {
155
+ const paths = weightedPaths(canvasElement);
156
+ expect(paths).toHaveLength(3);
157
+ const byVariant = (variant: string) => paths.filter((p) => p.dataset.variant === variant);
158
+ // Greyscale-safe: the back edge is dashed, the forward ones are not.
159
+ expect(byVariant("back")).toHaveLength(1);
160
+ expect(dashPattern(byVariant("back")[0]!)).toBe("6 4");
161
+ expect(byVariant("forward")).toHaveLength(2);
162
+ for (const forward of byVariant("forward")) {
163
+ expect(dashPattern(forward)).toBe("");
164
+ }
165
+ });
166
+
167
+ await canvas.findByRole("img", {
168
+ name: "Back edge — runs against the process direction",
169
+ });
170
+ },
171
+ };
172
+
173
+ /**
174
+ * The acceptance fixture, laid out for real: `A → B → C` with `C → B` and a
175
+ * `B → B` loop. `layoutFlow` reports which edge went backwards and which is a
176
+ * self-loop, and the story picks the edge type from that metadata alone.
177
+ */
178
+ export const ReworkLoopFromLayout: Story = {
179
+ render: () => {
180
+ const rawEdges: ProcessEdge[] = [
181
+ { id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 9 } },
182
+ { id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 6 } },
183
+ {
184
+ id: "e-cb",
185
+ source: "c",
186
+ target: "b",
187
+ type: "weighted",
188
+ data: { weight: 2, label: "18× reworked" },
189
+ },
190
+ {
191
+ id: "e-bb",
192
+ source: "b",
193
+ target: "b",
194
+ type: "weighted",
195
+ data: { weight: 3, label: "12×" },
196
+ },
197
+ ];
198
+ const { nodes, edges, backEdges, selfLoops } = layoutFlow(processNodes(), rawEdges, {
199
+ direction: "TB",
200
+ rankSpacing: 120,
201
+ });
202
+ const typed: ProcessEdge[] = edges.map((edge) => {
203
+ if (selfLoops.includes(edge.id)) {
204
+ return { ...edge, type: "self-loop" } as BrandFlowSelfLoopEdge;
205
+ }
206
+ if (backEdges.includes(edge.id)) {
207
+ return {
208
+ ...edge,
209
+ data: { ...edge.data, variant: "back" },
210
+ } as BrandFlowWeightedEdge;
211
+ }
212
+ return edge;
213
+ });
214
+ return (
215
+ <div className="h-[560px]">
216
+ <CanvasShell nodes={nodes} edges={typed} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
217
+ </div>
218
+ );
219
+ },
220
+ play: async ({ canvasElement }) => {
221
+ const canvas = within(canvasElement);
222
+
223
+ // Three greyscale-distinct signatures on one canvas: solid forward strokes,
224
+ // a dashed back edge, and a looping arc.
225
+ await waitFor(() => {
226
+ const weighted = weightedPaths(canvasElement);
227
+ expect(weighted.filter((p) => p.dataset.variant === "forward")).toHaveLength(2);
228
+ expect(weighted.filter((p) => p.dataset.variant === "back")).toHaveLength(1);
229
+ expect(loopPaths(canvasElement)).toHaveLength(1);
230
+ });
231
+
232
+ await canvas.findByRole("img", { name: "Back edge — runs against the process direction" });
233
+ await canvas.findByRole("img", { name: "Self-loop on Review — this step repeats" });
234
+ },
235
+ };
236
+
237
+ /** The same loop at decoration 0 and 10 — the arc and the dashes survive the drafting ground. */
238
+ export const Decoration: Story = {
239
+ render: () => {
240
+ const edges: ProcessEdge[] = [
241
+ { id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 9 } },
242
+ { id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 5 } },
243
+ {
244
+ id: "e-cb",
245
+ source: "c",
246
+ target: "b",
247
+ type: "weighted",
248
+ data: { weight: 2, variant: "back" },
249
+ },
250
+ { id: "e-bb", source: "b", target: "b", type: "self-loop", data: { weight: 3 } },
251
+ ];
252
+ return (
253
+ <div className="grid h-[520px] grid-cols-2">
254
+ {([0, 10] as const).map((level) => (
255
+ <div key={level} data-decoration={level} className="h-full bg-background">
256
+ <CanvasShell
257
+ nodes={processNodes()}
258
+ edges={edges.map((edge) => ({ ...edge, id: `d${level}-${edge.id}` }))}
259
+ nodeTypes={nodeTypes}
260
+ edgeTypes={edgeTypes}
261
+ />
262
+ </div>
263
+ ))}
264
+ </div>
265
+ );
266
+ },
267
+ play: async ({ canvasElement }) => {
268
+ await waitFor(() => {
269
+ expect(loopPaths(canvasElement)).toHaveLength(2);
270
+ expect(weightedPaths(canvasElement).filter((p) => p.dataset.variant === "back")).toHaveLength(
271
+ 2,
272
+ );
273
+ });
274
+ },
275
+ };
@@ -0,0 +1,196 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // @xyflow/react needs real layout/measurement — mock the engine and assert the
5
+ // brand component's own output, the same way `flow-weighted-edge.test.tsx`
6
+ // does. Real rendering + a11y are covered by the Storybook interaction tests.
7
+ //
8
+ // `vi.mock`'s factory is hoisted above every import, so the mock state lives in
9
+ // `vi.hoisted` to survive the hoist without a TDZ ReferenceError.
10
+ const { edgesBox, internalNodeBox } = vi.hoisted(() => ({
11
+ edgesBox: { current: [] as unknown[] },
12
+ internalNodeBox: { current: null as unknown },
13
+ }));
14
+
15
+ vi.mock("@xyflow/react", () => {
16
+ // 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
17
+ const React = require("react");
18
+ return {
19
+ BaseEdge: ({
20
+ id,
21
+ path,
22
+ style,
23
+ className,
24
+ ...rest
25
+ }: {
26
+ id: string;
27
+ path: string;
28
+ style?: React.CSSProperties;
29
+ className?: string;
30
+ }) =>
31
+ React.createElement("svg", { "data-testid": "base-edge" }, [
32
+ React.createElement("path", { key: "p", d: path, id, style, className, ...rest }),
33
+ ]),
34
+ // Real EdgeLabelRenderer portals into a fixed container; a passthrough is
35
+ // enough here since we only assert the brand component's own output.
36
+ EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => children,
37
+ useEdges: () => edgesBox.current,
38
+ useInternalNode: () => internalNodeBox.current,
39
+ Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
40
+ };
41
+ });
42
+
43
+ import type { EdgeProps } from "@xyflow/react";
44
+ import { FlowSelfLoopEdge, type BrandFlowSelfLoopEdge } from "./flow-self-loop-edge";
45
+ import { selfLoopHandleArc, selfLoopPath } from "./self-loop-geometry";
46
+
47
+ afterEach(() => {
48
+ cleanup();
49
+ edgesBox.current = [];
50
+ internalNodeBox.current = null;
51
+ });
52
+
53
+ /** A measured `InternalNode` stand-in: 200×60 box whose top-left is (100, 200). */
54
+ function measuredNode(data: unknown = { title: "Review" }) {
55
+ return {
56
+ id: "node-a",
57
+ data,
58
+ measured: { width: 200, height: 60 },
59
+ internals: { positionAbsolute: { x: 100, y: 200 } },
60
+ };
61
+ }
62
+
63
+ function makeEdgeProps(
64
+ overrides: Partial<EdgeProps<BrandFlowSelfLoopEdge>> = {},
65
+ ): EdgeProps<BrandFlowSelfLoopEdge> {
66
+ return {
67
+ id: "loop-1",
68
+ type: "self-loop",
69
+ source: "node-a",
70
+ target: "node-a",
71
+ sourceX: 200,
72
+ sourceY: 260,
73
+ targetX: 200,
74
+ targetY: 200,
75
+ sourcePosition: "bottom" as EdgeProps["sourcePosition"],
76
+ targetPosition: "top" as EdgeProps["targetPosition"],
77
+ selected: false,
78
+ animated: false,
79
+ data: {},
80
+ ...overrides,
81
+ };
82
+ }
83
+
84
+ const edgePath = () => screen.getByTestId("base-edge").querySelector("path")!;
85
+
86
+ /**
87
+ * The arc the fixture should produce: the 200×60 card at (100, 200), entered and left at
88
+ * the two handle points `makeEdgeProps` supplies.
89
+ */
90
+ const fixtureArc = (loopRadius = 28) =>
91
+ selfLoopHandleArc(
92
+ {
93
+ sourceX: 200,
94
+ sourceY: 260,
95
+ targetX: 200,
96
+ targetY: 200,
97
+ centerX: 200,
98
+ centerY: 230,
99
+ width: 200,
100
+ height: 60,
101
+ },
102
+ loopRadius,
103
+ );
104
+
105
+ describe("FlowSelfLoopEdge", () => {
106
+ it("starts and ends ON the two handle points, clearing the measured box between them", () => {
107
+ internalNodeBox.current = measuredNode();
108
+ render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
109
+ expect(edgePath()).toHaveAttribute("d", fixtureArc().path);
110
+ });
111
+
112
+ it("falls back to the handle midpoint before the node is measured — never NaN", () => {
113
+ internalNodeBox.current = { ...measuredNode(), measured: { width: 0, height: 0 } };
114
+ render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
115
+ const d = edgePath().getAttribute("d")!;
116
+ expect(d).not.toMatch(/NaN/);
117
+ expect(d).toBe(selfLoopPath({ centerX: 200, topY: 200 }, 28).path);
118
+ });
119
+
120
+ it("honours data.loopRadius", () => {
121
+ internalNodeBox.current = measuredNode();
122
+ render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { loopRadius: 60 } })} />);
123
+ expect(edgePath()).toHaveAttribute("d", fixtureArc(60).path);
124
+ });
125
+
126
+ it("carries its meaning as real text for assistive tech, not only a data attribute", () => {
127
+ internalNodeBox.current = measuredNode();
128
+ render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
129
+ const graphic = screen.getByRole("img", {
130
+ name: "Self-loop on Review — this step repeats",
131
+ });
132
+ expect(graphic).toBeInTheDocument();
133
+ // The data-slot is the test/styling seam, NOT the accessibility channel.
134
+ expect(graphic.querySelector('[data-slot="flow-self-loop-edge"]')).not.toBeNull();
135
+ });
136
+
137
+ it("names the node by id when its data carries no title, and accepts an override", () => {
138
+ internalNodeBox.current = measuredNode({});
139
+ const { unmount } = render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
140
+ expect(
141
+ screen.getByRole("img", { name: "Self-loop on node-a — this step repeats" }),
142
+ ).toBeInTheDocument();
143
+ unmount();
144
+
145
+ internalNodeBox.current = measuredNode();
146
+ render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { loopLabel: "Reworked 12 times" } })} />);
147
+ expect(screen.getByRole("img", { name: "Reworked 12 times" })).toBeInTheDocument();
148
+ });
149
+
150
+ it("is distinguishable from a forward edge without colour — the shape is the signal", () => {
151
+ internalNodeBox.current = measuredNode();
152
+ render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
153
+ const path = edgePath();
154
+ // A cubic that returns to its own node: it leaves the bottom handle (200, 260) and
155
+ // re-enters at the top one (200, 200) — the two ends bracket the card rather than
156
+ // spanning a gap, which is what no forward edge ever does.
157
+ expect(path.getAttribute("d")).toMatch(/^M 200,260 C .* 200,200$/);
158
+ expect(path.style.stroke).toBe("var(--flow-edge)");
159
+ expect(path.style.fill).toBe("none");
160
+ });
161
+
162
+ it("shares one weight scale with the forward edges around it", () => {
163
+ internalNodeBox.current = measuredNode();
164
+ edgesBox.current = [
165
+ { id: "fwd-min", data: { weight: 1 } },
166
+ { id: "loop-1", data: { weight: 10 } },
167
+ { id: "fwd-mid", data: { weight: 5 } },
168
+ ];
169
+ render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { weight: 10 } })} />);
170
+ // Top of the shared [1.5, 8] range, because it is the heaviest edge present.
171
+ expect(edgePath().style.strokeWidth).toBe("8");
172
+ });
173
+
174
+ it("uses the --ring token when selected, matching FlowNode's selected treatment", () => {
175
+ internalNodeBox.current = measuredNode();
176
+ render(<FlowSelfLoopEdge {...makeEdgeProps({ selected: true })} />);
177
+ expect(edgePath().style.stroke).toBe("var(--ring)");
178
+ });
179
+
180
+ it("renders an EdgeLabelPill at the apex when labelled, and none when not", () => {
181
+ internalNodeBox.current = measuredNode();
182
+ const { unmount } = render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
183
+ expect(screen.queryByRole("button")).toBeNull();
184
+ unmount();
185
+
186
+ render(
187
+ <FlowSelfLoopEdge {...makeEdgeProps({ data: { label: "12×", secondaryLabel: "2.1d" } })} />,
188
+ );
189
+ const pill = screen.getByRole("button", { name: "12× · 2.1d" });
190
+ // The arc's widest point: 328 is the card's right edge (300) plus the 28px loop
191
+ // radius, and 230 is the card's own vertical centre — i.e. beside the node, not on it.
192
+ const { labelX, labelY } = fixtureArc();
193
+ expect([labelX, labelY]).toEqual([328, 230]);
194
+ expect(pill.parentElement!.style.transform).toContain(`translate(${labelX}px, ${labelY}px)`);
195
+ });
196
+ });