@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.
Files changed (66) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +734 -20
  3. package/dist/index.js +976 -178
  4. package/dist/index.js.map +1 -1
  5. package/package.json +6 -6
  6. package/src/canvas-shell/canvas-shell.tsx +116 -1
  7. package/src/canvas-shell/use-measured-nodes.ts +101 -0
  8. package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
  9. package/src/flow-button-edge/flow-button-edge.tsx +8 -10
  10. package/src/flow-edge/flow-edge.stories.tsx +20 -0
  11. package/src/flow-edge/flow-edge.tsx +10 -3
  12. package/src/flow-edge-path/flow-edge-path.tsx +149 -0
  13. package/src/flow-edge-path/index.ts +1 -0
  14. package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
  15. package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
  16. package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
  17. package/src/flow-group-node/flow-group-node.tsx +24 -8
  18. package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
  19. package/src/flow-handle/flow-handle-anchor.ts +36 -0
  20. package/src/flow-handle/index.ts +1 -0
  21. package/src/flow-layout/flow-layout.stories.tsx +2 -2
  22. package/src/flow-layout/flow-layout.test.tsx +91 -0
  23. package/src/flow-layout/flow-layout.ts +77 -1
  24. package/src/flow-layout/layout-graph.test.ts +83 -2
  25. package/src/flow-layout/layout-graph.ts +23 -15
  26. package/src/flow-mini-map/flow-mini-map.stories.tsx +86 -0
  27. package/src/flow-node/flow-node.stories.tsx +151 -0
  28. package/src/flow-node/flow-node.tsx +56 -1
  29. package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
  30. package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
  31. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
  32. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
  33. package/src/flow-self-loop-edge/index.ts +13 -0
  34. package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
  35. package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
  36. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
  37. package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
  38. package/src/flow-smart-edge/index.ts +5 -1
  39. package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
  40. package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
  41. package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
  42. package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
  43. package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
  44. package/src/flow-weighted-edge/edge-aria.ts +117 -0
  45. package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
  46. package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
  47. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
  48. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
  49. package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
  50. package/src/flow-weighted-edge/index.ts +18 -0
  51. package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
  52. package/src/flow-weighted-edge/weight-scale.ts +86 -0
  53. package/src/index.ts +9 -0
  54. package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
  55. package/src/inspector-panel/inspector-panel.test.tsx +20 -0
  56. package/src/inspector-panel/inspector-panel.tsx +13 -3
  57. package/src/legend/index.ts +7 -1
  58. package/src/legend/legend.stories.tsx +126 -0
  59. package/src/legend/legend.test.tsx +169 -0
  60. package/src/legend/legend.tsx +214 -3
  61. package/src/templates-flow-workspace.stories.tsx +1 -1
  62. package/src/testing/canvas-framing.test.ts +107 -0
  63. package/src/testing/canvas-framing.ts +396 -0
  64. package/src/testing/edge-anchors.ts +107 -0
  65. package/src/testing/index.ts +36 -0
  66. package/src/zoom-controls/zoom-controls.tsx +1 -1
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import "@xyflow/react/dist/style.css";
3
+ import { expect, waitFor } from "storybook/test";
3
4
  import { CanvasShell } from "../canvas-shell";
4
5
  import { FlowNode, FLOW_ALL_SIDE_HANDLES, type BrandFlowNode } from "../flow-node";
5
6
  import { FlowSmartEdge } from "../flow-smart-edge";
@@ -71,4 +72,89 @@ export const Default: Story = {
71
72
  </CanvasShell>
72
73
  </div>
73
74
  ),
75
+ /**
76
+ * The minimap must actually DRAW the nodes — this story rendered a blank white panel
77
+ * for as long as it has existed, and nothing caught it: React Flow's `<MiniMap>` reads
78
+ * each node's dimensions off the object the CONSUMER passed (`internals.userNode`), and
79
+ * bails out per node when they are absent. On a controlled canvas with no
80
+ * `onNodesChange` applying React Flow's own `dimensions` changes back, they always were.
81
+ * `CanvasShell` now merges the measurements in (`useMeasuredNodes`), so the count below
82
+ * is the honest proof — asserting the `<svg>` merely exists passes on the broken state.
83
+ *
84
+ * The count alone is not the whole issue (#363): its second hypothesis was a
85
+ * `nodeColor`/`nodeStrokeColor` token resolving to nothing, which paints the same
86
+ * rects INVISIBLY rather than omitting them — a count-only assertion would pass on
87
+ * that failure too. So this also resolves the rect's actual painted fill against the
88
+ * panel's actual painted background and demands real contrast, at the FILL-rung floor
89
+ * this repo already holds status marks to (`styling-and-tokens.md`). Same
90
+ * canvas-readback pattern `FlowNode`'s `FocusIndicator` / `FlowWeightedEdge`'s
91
+ * `KeyboardFocus` locks use to turn a CSS colour string (`oklch()` included) into a
92
+ * real measurement instead of an assumption.
93
+ */
94
+ play: async ({ canvasElement }) => {
95
+ let minimap!: HTMLElement;
96
+ await waitFor(() => {
97
+ const el = canvasElement.querySelector<HTMLElement>(".react-flow__minimap");
98
+ expect(el).toBeTruthy();
99
+ expect(el!.querySelectorAll(".react-flow__minimap-node")).toHaveLength(nodes.length);
100
+ // The viewport mask is derived from the transform, not from node geometry — it
101
+ // never broke, but the fix for this issue touches nothing about it either, so
102
+ // this pins it as unchanged rather than leaving it unasserted.
103
+ expect(el!.querySelector(".react-flow__minimap-mask")).toBeTruthy();
104
+ minimap = el!;
105
+ });
106
+
107
+ // `getImageData()` reports straight (non-premultiplied) RGBA — the alpha channel is
108
+ // real, but reading r/g/b alone discards it: a fully transparent fill (alpha 0) still
109
+ // returns SOME rgb triple (typically 0,0,0), which can measure as opaque black and pass
110
+ // contrast against a light panel while nothing is actually painted. Compositing the
111
+ // fill ON TOP OF the real panel background first — the same source-over the browser
112
+ // performs when it paints the rect — means a zero-alpha fill reads back AS the panel
113
+ // background, so it can never clear the threshold below (#409 review).
114
+ const toSrgbOverBackground = (colour: string, background: string): [number, number, number] => {
115
+ const surface = document.createElement("canvas");
116
+ surface.width = 1;
117
+ surface.height = 1;
118
+ const ctx = surface.getContext("2d")!;
119
+ ctx.fillStyle = background;
120
+ ctx.fillRect(0, 0, 1, 1);
121
+ ctx.fillStyle = colour;
122
+ ctx.fillRect(0, 0, 1, 1);
123
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
124
+ return [r! / 255, g! / 255, b! / 255];
125
+ };
126
+ const luminance = ([r, g, b]: [number, number, number]) => {
127
+ const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
128
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
129
+ };
130
+ const contrastOverBackground = (fill: string, background: string) => {
131
+ const [hi, lo] = [
132
+ luminance(toSrgbOverBackground(fill, background)),
133
+ luminance(toSrgbOverBackground(background, background)),
134
+ ].sort((x, y) => y - x);
135
+ return (hi! + 0.05) / (lo! + 0.05);
136
+ };
137
+
138
+ const panelBackground = getComputedStyle(minimap).backgroundColor;
139
+
140
+ // The whole point of this helper is that a blank minimap must FAIL the check — lock
141
+ // that directly: a fully transparent fill composites to the panel background itself,
142
+ // so it can never satisfy the ≥3 contrast assertion below (would incorrectly read as
143
+ // 21:1 "black on white" if alpha were discarded, per the #409 review finding).
144
+ expect(
145
+ contrastOverBackground("rgba(0, 0, 0, 0)", panelBackground),
146
+ "a fully transparent fill must not be able to satisfy the contrast check",
147
+ ).toBeLessThan(3);
148
+
149
+ const rects = minimap.querySelectorAll<SVGRectElement>(".react-flow__minimap-node");
150
+ for (const rect of rects) {
151
+ expect(rect.width.baseVal.value, "minimap node rect has zero width").toBeGreaterThan(0);
152
+ expect(rect.height.baseVal.value, "minimap node rect has zero height").toBeGreaterThan(0);
153
+ const fill = getComputedStyle(rect).fill;
154
+ expect(
155
+ contrastOverBackground(fill, panelBackground),
156
+ `minimap node fill (${fill}) is not distinguishable from the panel background (${panelBackground})`,
157
+ ).toBeGreaterThanOrEqual(3);
158
+ }
159
+ },
74
160
  };
@@ -1,6 +1,7 @@
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, userEvent, waitFor } from "storybook/test";
4
5
  import { CanvasShell } from "../canvas-shell";
5
6
  import { FlowNode, type BrandFlowNode } from "./flow-node";
6
7
 
@@ -141,3 +142,153 @@ export const Connected: Story = {
141
142
  );
142
143
  },
143
144
  };
145
+
146
+ /**
147
+ * Regression lock for #312 — `FlowNode` was keyboard-focusable and painted NO
148
+ * focus indicator at all. React Flow puts `tabIndex`/`:focus-visible` on its
149
+ * own wrapper (`.react-flow__node`), one level above the `<div>` this
150
+ * component renders, so the lock reaches the real tab stop by keyboard alone
151
+ * (no synthetic `.focus()`) and reads RESOLVED computed styles on the
152
+ * component's own div — not just a class string — because a previous defect
153
+ * on this exact surface measured 0 changed pixels out of 540,000 while the
154
+ * class list looked correct.
155
+ *
156
+ * Two nodes: a plain one (proves focus alone paints an indicator) and a
157
+ * pre-selected one (proves the NEW focus outline is a distinct, additional
158
+ * layer over the EXISTING `selected` ring — the two never collapse into one
159
+ * ring, and `selected` on its own never gains the outline).
160
+ */
161
+ export const FocusIndicator: Story = {
162
+ render: () => {
163
+ const nodes: BrandFlowNode[] = [
164
+ {
165
+ id: "plain",
166
+ type: "brand",
167
+ position: { x: 40, y: 40 },
168
+ data: { title: "Plain node" },
169
+ },
170
+ {
171
+ id: "chosen",
172
+ type: "brand",
173
+ position: { x: 320, y: 40 },
174
+ selected: true,
175
+ data: { kind: "Output", title: "Selected node", tone: "success" },
176
+ },
177
+ ];
178
+ return (
179
+ <div className="h-[220px]">
180
+ <CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes} />
181
+ </div>
182
+ );
183
+ },
184
+ play: async ({ canvasElement }) => {
185
+ // Resolve ANY CSS colour string down to sRGB so a contrast ratio is a
186
+ // measurement, not an assumption — same helper `FlowWeightedEdge`'s
187
+ // `KeyboardFocus` lock uses for the edge half of this same fix family.
188
+ const toSrgb = (colour: string): [number, number, number] => {
189
+ const surface = document.createElement("canvas");
190
+ surface.width = 1;
191
+ surface.height = 1;
192
+ const ctx = surface.getContext("2d")!;
193
+ ctx.fillStyle = colour;
194
+ ctx.fillRect(0, 0, 1, 1);
195
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
196
+ return [r! / 255, g! / 255, b! / 255];
197
+ };
198
+ const luminance = ([r, g, b]: [number, number, number]) => {
199
+ const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
200
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
201
+ };
202
+ const contrast = (a: string, b: string) => {
203
+ const [hi, lo] = [luminance(toSrgb(a)), luminance(toSrgb(b))].sort((x, y) => y - x);
204
+ return (hi! + 0.05) / (lo! + 0.05);
205
+ };
206
+
207
+ let plainWrapper!: HTMLElement;
208
+ let plainDiv!: HTMLElement;
209
+ let chosenWrapper!: HTMLElement;
210
+ let chosenDiv!: HTMLElement;
211
+ await waitFor(() => {
212
+ const pw = canvasElement.querySelector<HTMLElement>('[data-testid="rf__node-plain"]');
213
+ const cw = canvasElement.querySelector<HTMLElement>('[data-testid="rf__node-chosen"]');
214
+ expect(pw).not.toBe(null);
215
+ expect(cw).not.toBe(null);
216
+ plainWrapper = pw!;
217
+ chosenWrapper = cw!;
218
+ plainDiv = pw!.querySelector<HTMLElement>("[data-tone]")!;
219
+ chosenDiv = cw!.querySelector<HTMLElement>("[data-tone]")!;
220
+ expect(plainDiv).not.toBe(null);
221
+ expect(chosenDiv).not.toBe(null);
222
+ });
223
+
224
+ // Resting: NEITHER node paints the focus outline. The pre-selected node
225
+ // already shows its selection ring (driven by the `selected` prop, not a
226
+ // CSS state) — that ring is untouched by this fix and must stay exactly
227
+ // as it was.
228
+ const restingPlainShadow = getComputedStyle(plainDiv).boxShadow;
229
+ const restingChosenShadow = getComputedStyle(chosenDiv).boxShadow;
230
+ await expect(getComputedStyle(plainDiv).outlineStyle).toBe("none");
231
+ await expect(getComputedStyle(chosenDiv).outlineStyle).toBe("none");
232
+ await expect(restingChosenShadow).not.toBe("none");
233
+
234
+ // Reach the plain node by keyboard alone — real tab order, no synthetic .focus().
235
+ let guard = 0;
236
+ while (document.activeElement !== plainWrapper && guard < 40) {
237
+ await userEvent.tab();
238
+ guard += 1;
239
+ }
240
+ await expect(document.activeElement).toBe(plainWrapper);
241
+
242
+ // Focused, unselected: a RESOLVED computed value changed, not just a
243
+ // class string.
244
+ await waitFor(() => {
245
+ expect(getComputedStyle(plainDiv).outlineStyle).toBe("solid");
246
+ });
247
+ await expect(getComputedStyle(plainDiv).outlineWidth).toBe("1px");
248
+ await expect(getComputedStyle(plainDiv).boxShadow).not.toBe(restingPlainShadow);
249
+
250
+ // The compound indicator clears WCAG 1.4.11 (3:1) against the node's own
251
+ // ground — via AT LEAST ONE of its two layers. Per ADR 0027 Amendment 2
252
+ // the bar is `max(contrast(--ring, S), contrast(--ring-contour, S)) >= 3:1`:
253
+ // in `dark`, `--ring-contour` is a deliberate no-op aliased to
254
+ // `--background` because the ring layer alone already clears the bar, so
255
+ // checking the contour alone (rather than the max of both layers) would
256
+ // fail here even though the indicator is genuinely visible. This is a
257
+ // rendered-surface re-check of the guarantee `themes-contrast.test.ts`'s
258
+ // `INDICATOR_SURFACES` already locks at the token level for
259
+ // `--flow-node`/`--canvas` — not a new bar.
260
+ const nodeGround = getComputedStyle(plainDiv).backgroundColor;
261
+ const contourInk = getComputedStyle(plainDiv).outlineColor;
262
+ const ringInk = getComputedStyle(plainDiv).getPropertyValue("--ring").trim();
263
+ const contourRatio = contrast(contourInk, nodeGround);
264
+ const ringRatio = contrast(ringInk, nodeGround);
265
+ const bestRatio = Math.max(contourRatio, ringRatio);
266
+ await expect(
267
+ bestRatio,
268
+ `focus indicator vs node ground: contour ${contourInk} = ${contourRatio.toFixed(2)}:1, ring ${ringInk} = ${ringRatio.toFixed(2)}:1`,
269
+ ).toBeGreaterThanOrEqual(3);
270
+
271
+ // Continue tabbing to the pre-selected node.
272
+ guard = 0;
273
+ while (document.activeElement !== chosenWrapper && guard < 40) {
274
+ await userEvent.tab();
275
+ guard += 1;
276
+ }
277
+ await expect(document.activeElement).toBe(chosenWrapper);
278
+
279
+ // Selected AND focused: the outline is the ADDITIONAL, distinguishing
280
+ // layer — the shared ring layer resolves to the exact same box-shadow the
281
+ // selection alone already painted, so "selected" never silently gains a
282
+ // second, indistinguishable ring; only the new outline signals focus.
283
+ await waitFor(() => {
284
+ expect(getComputedStyle(chosenDiv).outlineStyle).toBe("solid");
285
+ });
286
+ await expect(getComputedStyle(chosenDiv).boxShadow).toBe(restingChosenShadow);
287
+
288
+ // Blur restores the resting state.
289
+ await userEvent.tab();
290
+ await waitFor(() => {
291
+ expect(getComputedStyle(chosenDiv).outlineStyle).toBe("none");
292
+ });
293
+ },
294
+ };
@@ -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
+ };