@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
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
- import { ReactNode, SVGAttributes } from 'react';
3
- import { Node, Edge, ReactFlowProps, NodeProps, EdgeProps, Position, NodeChange, MiniMapProps, PanelPosition } from '@xyflow/react';
2
+ import { ReactNode, SVGProps, CSSProperties, SVGAttributes, ComponentPropsWithoutRef } from 'react';
3
+ import { Node, Edge, ReactFlowProps, FitViewOptions, NodeProps, EdgeProps, Position, NodeChange, MiniMapProps, PanelPosition } from '@xyflow/react';
4
4
  export { Background, Connection, Controls, Edge, EdgeProps, MiniMap, Node, NodeProps, Panel, Position, ReactFlow, ReactFlowProvider, addEdge, useEdgesState, useNodesState, useReactFlow } from '@xyflow/react';
5
5
 
6
6
  interface CanvasShellProps<NodeType extends Node = Node, EdgeType extends Edge = Edge> extends ReactFlowProps<NodeType, EdgeType> {
@@ -15,6 +15,26 @@ interface CanvasShellProps<NodeType extends Node = Node, EdgeType extends Edge =
15
15
  * use the exported `useHelperLines` + `<HelperLines>` directly.
16
16
  */
17
17
  helperLines?: boolean;
18
+ /**
19
+ * Re-fit the viewport whenever this value changes.
20
+ *
21
+ * React Flow's own `fitView` prop fits ONCE, as soon as the nodes are first measured,
22
+ * and then never again. A canvas whose positions arrive AFTER that first paint —
23
+ * anything laid out asynchronously, e.g. a debounced `layoutFlow` pass — therefore gets
24
+ * fitted to the pre-layout positions (typically every node still stacked at the origin)
25
+ * and stays there. Measured on the process map: the viewport pinned at React Flow's
26
+ * `maxZoom` of 2 with 2 of 11 nodes on screen, while the layout underneath was correct
27
+ * the whole time.
28
+ *
29
+ * Pass the key the layout is cached on (structure + direction), NOT something that
30
+ * changes on every render and not a metric: a re-fit is a viewport jump, so it must
31
+ * happen when the picture genuinely moved and never while the reader is reading. The
32
+ * fit waits for `useNodesInitialized`, so it measures real node boxes rather than
33
+ * fitting a degenerate bounding box a second time.
34
+ */
35
+ fitViewKey?: string | number;
36
+ /** Options for the {@link fitViewKey} re-fit. Ignored without one. */
37
+ fitViewKeyOptions?: FitViewOptions;
18
38
  /** Overlays rendered inside the flow (ZoomControls, Legend, Panels). */
19
39
  children?: ReactNode;
20
40
  className?: string;
@@ -31,6 +51,43 @@ interface CanvasShellProps<NodeType extends Node = Node, EdgeType extends Edge =
31
51
  */
32
52
  declare function CanvasShell<NodeType extends Node = Node, EdgeType extends Edge = Edge>({ helperLines, ...props }: CanvasShellProps<NodeType, EdgeType>): react.JSX.Element;
33
53
 
54
+ /**
55
+ * The class every React Flow `<Handle>` in this package carries — and the one a
56
+ * consumer's own custom node must carry too.
57
+ *
58
+ * A connector dot is a MEASUREMENT ANCHOR, not a moving part. React Flow measures
59
+ * `handleBounds` from the DOM exactly once per layout change (`updateNodeInternals`,
60
+ * fired from the effect that sees `sourcePosition`/`targetPosition` change) and then
61
+ * draws every edge endpoint from that stored number until something invalidates it.
62
+ * So a dot that is still ON ITS WAY to its new side when that measurement is taken is
63
+ * measured at the wrong place — permanently, because nothing measures again.
64
+ *
65
+ * That is not hypothetical, and it is not only about author-written transitions:
66
+ *
67
+ * - `@elabs-ai/components-tokens`' reduced-motion backstop (`themes.css`, MOTION GATE)
68
+ * sets `transition-duration: 0.01ms !important` on `*` so third-party engines that
69
+ * ignore the `--t-*` tokens (Monaco, `@xyflow/react`, Streamdown) cannot animate.
70
+ * `transition-property` is left at its initial value, `all` — so under
71
+ * `prefers-reduced-motion: reduce` that rule does not remove a transition from a
72
+ * handle, it CREATES one: every geometric property of every element becomes
73
+ * transitioned, for one frame.
74
+ * - One frame is all it takes. Measured on `ProcessMap direction="LR"`: at the commit
75
+ * that flips the handles from top/bottom to left/right, the dot's computed box was
76
+ * still the OLD one (`getAnimations()` on it returned live `left`+`top`+`transform`
77
+ * transitions), React Flow measured `{ x: 164, y: 45.5 }` where the settled DOM has
78
+ * `{ x: 172, y: 37.5 }`, and every edge on the map then terminated up to 67 px away
79
+ * from the dot it points at — for reduced-motion readers only, forever.
80
+ *
81
+ * `transition-property: none` is the fix at the right layer: it costs nothing under
82
+ * normal motion (no rule animates a handle there anyway) and it makes the reposition
83
+ * synchronous, so whenever React Flow measures, it measures the settled dot.
84
+ *
85
+ * The literal is written out here rather than assembled, because Tailwind extracts
86
+ * candidates from source TEXT: this file is scanned, so the utility is emitted, and
87
+ * components may interpolate the constant freely.
88
+ */
89
+ declare const FLOW_HANDLE_ANCHOR_CLASS = "transition-none";
90
+
34
91
  /** A node side that can carry a handle. Doubles as the handle's stable id. */
35
92
  type FlowHandleSide = "top" | "right" | "bottom" | "left";
36
93
  /**
@@ -66,28 +123,166 @@ interface FlowNodeData extends Record<string, unknown> {
66
123
  * bottom-source (unchanged, backward-compatible).
67
124
  */
68
125
  handles?: FlowNodeHandles;
126
+ /**
127
+ * An extra row rendered INSIDE the card, below the text block — a meter bar, a
128
+ * sparkline, a chip row.
129
+ *
130
+ * It exists because content a composing package renders BESIDE `FlowNode` (as a sibling
131
+ * inside React Flow's node element) silently breaks the canvas's geometry: React Flow
132
+ * positions every `<Handle>` against the nearest positioned ancestor and measures the
133
+ * node box from its own wrapper, so a sibling row makes the node box taller than the
134
+ * visible card and the handles drift off the card's border by exactly that difference.
135
+ * Measured on the process map's activity node, whose 6px meter and 4px gap put every
136
+ * bottom dot 10px below the card it was supposed to sit on, and every left/right dot
137
+ * 5px below the card's own mid-line.
138
+ *
139
+ * Put the row here instead and the card IS the node box again, so the dots land on the
140
+ * card edge for free. Nothing is rendered when it is absent — existing nodes are
141
+ * byte-identical.
142
+ */
143
+ footer?: ReactNode;
69
144
  }
70
145
  type BrandFlowNode = Node<FlowNodeData, "brand">;
71
146
  /**
72
147
  * Branded custom node. Register it in `nodeTypes={{ brand: FlowNode }}` and
73
148
  * create nodes with `type: "brand"` and `data: FlowNodeData`.
149
+ *
150
+ * ## Focus vs selection (#312)
151
+ *
152
+ * `selected && "ring-2 ring-ring"` below is a SELECTION marker, not a focus
153
+ * indicator — it is React Flow's own click-driven `selected` state and is the
154
+ * genuine-selection carve-out `.claude/rules/theming.md` names explicitly.
155
+ * Keyboard focus is a separate, independent signal this component used to omit
156
+ * entirely (issue #312): React Flow puts `tabIndex`/`role="group"` and the real
157
+ * `:focus-visible` state on **its own wrapper** (`.react-flow__node`, which
158
+ * also always carries a `data-id` attribute), one level ABOVE the `<div>` this
159
+ * component returns — so neither `focus-ring` (`:focus-visible` on self) nor
160
+ * `focus-ring-within` (`:focus-within`, a focused descendant) can ever fire
161
+ * here; focus is PROXIED to an ancestor this component doesn't render.
162
+ * `focus-ring-static` is the flavour built for that exact shape (ADR 0027),
163
+ * gated by an ancestor-selector arbitrary variant — the same idiom
164
+ * `FlowEdgePath` uses for `.react-flow__edge:focus-visible`, keyed on
165
+ * `[data-id]` here (rather than the escaped `.react-flow\_\_node` class) so
166
+ * the selector needs no backslash escaping inside a plain JS string — a
167
+ * literal `\_` in a `cn()` argument is a real JS string escape and would be
168
+ * silently stripped at runtime (unlike in a bare, unbraced JSX attribute,
169
+ * where backslashes are never processed — the reason `FlowEdgePath` can use
170
+ * the class form safely and this component, composing through `cn()`, cannot).
171
+ * The two signals compose without merging into one ring: `selected` alone
172
+ * paints the ring layer only, while a focused node additionally gets the
173
+ * `--ring-contour` outline drawn outside it, so "selected AND focused" reads
174
+ * as two visible layers, not the single ring "selected alone" paints.
74
175
  */
75
176
  declare function FlowNode({ data, selected, sourcePosition, targetPosition, }: NodeProps<BrandFlowNode>): react.JSX.Element;
76
177
 
77
178
  /**
78
179
  * Branded bezier edge using the `--flow-edge` token. Register it in
79
180
  * `edgeTypes={{ brand: FlowEdge }}` and create edges with `type: "brand"`.
181
+ *
182
+ * Drawn through `FlowEdgePath`, so it carries the shared keyboard focus
183
+ * indicator (#286) — never reach for React Flow's `BaseEdge` directly.
80
184
  */
81
185
  declare function FlowEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, markerEnd, style, }: EdgeProps): react.JSX.Element;
82
186
 
83
187
  /**
84
- * Branded edge that picks the closest source/target handle pair (from a node's
85
- * multi-side `FlowNodeHandles` config) and routes a bezier between them. Anchors
86
- * are recomputed every render, so they flip as nodes are dragged. Register it in
87
- * `edgeTypes={{ smart: FlowSmartEdge }}` and give the connected nodes a
88
- * `data.handles` config. Uses the `--flow-edge` token, matching `FlowEdge`.
188
+ * Extra stroke width, in px, of the `--ring` band drawn around a focused edge.
189
+ * The band sits *outside* the edge's own stroke, so it is visible whatever the
190
+ * edge's resting width is.
191
+ */
192
+ declare const FLOW_EDGE_FOCUS_RING_WIDTH = 3;
193
+ /**
194
+ * Extra stroke width, in px, of the neutral contour drawn outside the `--ring`
195
+ * band. This is the layer that carries the WCAG 1.4.11 bar: `--foreground`
196
+ * measures 12.50:1 against `--canvas` in `light` and 16.25:1 in `dark`, where
197
+ * `--ring` alone measures 1.30:1 in `light` (issue #286).
198
+ */
199
+ declare const FLOW_EDGE_FOCUS_CONTOUR_WIDTH = 6;
200
+ interface FlowEdgePathProps extends Omit<SVGProps<SVGPathElement>, "path" | "stroke" | "strokeWidth" | "style" | "ref"> {
201
+ /** SVG path `d` for this edge, from `getBezierPath`/`getSmoothStepPath`/… */
202
+ path: string;
203
+ /** Resting stroke paint — a `var(--token)` reference or a resolved colour. */
204
+ stroke: string;
205
+ /** Resting stroke width in px. The focus layers are drawn wider than this. */
206
+ strokeWidth: number;
207
+ /** Dash pattern, applied to the edge AND to both focus layers so a dashed edge keeps its shape when focused. */
208
+ strokeDasharray?: string;
209
+ /** Stroke opacity for the edge itself. Never applied to the focus layers. */
210
+ strokeOpacity?: number;
211
+ /** React Flow marker url, e.g. from `EdgeProps.markerEnd`. */
212
+ markerEnd?: string;
213
+ /** React Flow marker url, e.g. from `EdgeProps.markerStart`. */
214
+ markerStart?: string;
215
+ /** Width of the invisible pointer-target path React Flow draws over the edge. */
216
+ interactionWidth?: number;
217
+ /** Merged onto the edge path, last — so a consumer's `style.stroke` still wins. */
218
+ style?: CSSProperties;
219
+ }
220
+ /**
221
+ * The edge path every brand edge type draws, with the keyboard focus indicator
222
+ * built in. **Use this instead of React Flow's `BaseEdge`** — a custom edge that
223
+ * reaches for `BaseEdge` directly ships with no focus indicator at all, which is
224
+ * the defect issue #286 records (and `no-raw-base-edge.test.ts` fails on).
225
+ *
226
+ * ## Why the indicator is drawn rather than restyled
227
+ *
228
+ * React Flow zeroes the native outline on a focused edge
229
+ * (`.react-flow__edge:focus-visible { outline: none }`) and substitutes a stroke
230
+ * recolour on `.react-flow__edge-path`. Every brand edge passes its stroke as an
231
+ * **inline style** on that exact path — `BaseEdge` spreads `style` onto it — and
232
+ * an inline declaration beats any stylesheet rule, so the substitute never
233
+ * painted. Focusing an edge changed nothing on screen: WCAG 2.4.7 failed on
234
+ * every edge of every canvas, in both themes.
235
+ *
236
+ * The obvious repair — move the stroke into a custom property so a stylesheet
237
+ * rule can reach it — does **not** work here, and that was measured rather than
238
+ * assumed: React Flow's own `.react-flow__edge-path { stroke: … }` ships
239
+ * **unlayered**, and unlayered CSS outranks anything in `@layer utilities`,
240
+ * where Tailwind puts every utility. A `stroke-[var(--flow-edge-stroke)]` class
241
+ * would therefore lose to React Flow's `#b1b1b7` default and repaint every edge
242
+ * in the library. `!important` and setting `--xy-edge-stroke-selected` were both
243
+ * rejected in the issue.
244
+ *
245
+ * So the indicator is drawn on two paths this component owns, under the edge and
246
+ * on the same geometry. Nothing can shadow them: not React Flow's stylesheet,
247
+ * not a consumer's inline `style.stroke`, not a future edge type's own painting.
248
+ * They are hidden (`opacity-0`) until the ancestor `g.react-flow__edge` matches
249
+ * `:focus-visible`, which is a plain descendant selector on classes only this
250
+ * component uses.
251
+ *
252
+ * ## Compound, because one colour is not enough
253
+ *
254
+ * The indicator is two layers, per the compound-indicator recipe in
255
+ * `.claude/rules/theming.md`: a neutral `--foreground` contour at
256
+ * `strokeWidth + 6`, and the `--ring` band at `strokeWidth + 3` over it. The
257
+ * contour is the layer that clears WCAG 1.4.11's 3:1 bar against `--canvas` in
258
+ * both reference themes (12.50:1 `light`, 16.25:1 `dark`); a bare `--ring`
259
+ * stroke would not (1.30:1 in `light`). It is opacity + stroke only — no
260
+ * shadow — so it survives `data-decoration="8|9|10"`, which goes shadowless.
261
+ */
262
+ declare function FlowEdgePath({ path, stroke, strokeWidth, strokeDasharray, strokeOpacity, markerEnd, markerStart, interactionWidth, className, style, ...props }: FlowEdgePathProps): react.JSX.Element;
263
+
264
+ /**
265
+ * Branded edge that picks the closest source/target **handle** pair and routes a
266
+ * bezier between them. Anchors are recomputed every render, so they flip as
267
+ * nodes are dragged. Register it in `edgeTypes={{ smart: FlowSmartEdge }}`; give
268
+ * the connected nodes a `data.handles` config (e.g. `FLOW_ALL_SIDE_HANDLES`) to
269
+ * offer it more than the default two anchors. Uses the `--flow-edge` token,
270
+ * matching `FlowEdge`.
271
+ *
272
+ * ## The path terminates ON the handle dot
273
+ *
274
+ * The endpoints come from React Flow's **measured `handleBounds`** — the centre
275
+ * of the rendered dot — not from a point derived from the node's rectangle.
276
+ * That is a correctness property, not a refinement: an earlier version slid each
277
+ * anchor along the chosen side toward the other node (to fan out edges sharing a
278
+ * side) and picked sides from a four-way fallback list, so a line could meet the
279
+ * node up to half a side away from any dot — measured at ~22px on multi-side
280
+ * nodes and ~124px on nodes carrying only the default top/bottom handles, where
281
+ * a left/right side with no handle at all could be chosen. Two edges leaving the
282
+ * same handle now leave from the same point and diverge, exactly as React Flow's
283
+ * own edges do.
89
284
  */
90
- declare function FlowSmartEdge({ id, source, target, markerEnd, style }: EdgeProps): react.JSX.Element | null;
285
+ declare function FlowSmartEdge({ id, source, target, sourceHandleId, targetHandleId, markerEnd, style, }: EdgeProps): react.JSX.Element | null;
91
286
 
92
287
  /** Axis-aligned node rectangle in absolute flow coordinates. */
93
288
  interface NodeRect {
@@ -100,12 +295,48 @@ interface NodeRect {
100
295
  declare const HANDLE_SIDES: FlowHandleSide[];
101
296
  /** Maps a handle side to the React Flow `Position` used for bezier control. */
102
297
  declare const sideToPosition: Record<FlowHandleSide, Position>;
298
+ /** Maps a React Flow `Position` back to the handle side it names. */
299
+ declare const positionToSide: Record<Position, FlowHandleSide>;
300
+ /**
301
+ * One candidate connection point: the **measured centre of a rendered handle
302
+ * dot**, in absolute flow coordinates, plus the side it sits on.
303
+ *
304
+ * This is the unit `FlowSmartEdge` routes between. Anchoring on a measured
305
+ * handle — rather than on a point derived from the node's rectangle — is what
306
+ * guarantees the drawn path terminates exactly on the dot the user sees,
307
+ * whatever the handle's size, offset or CSS. See {@link pickClosestAnchors}.
308
+ */
309
+ interface HandleAnchor {
310
+ /** The handle's `id`, when it has one (`FlowNode` uses the side name). */
311
+ id: string | null;
312
+ /** Absolute x of the handle dot's centre. */
313
+ x: number;
314
+ /** Absolute y of the handle dot's centre. */
315
+ y: number;
316
+ /** The node side the handle sits on — the bezier's control direction. */
317
+ side: FlowHandleSide;
318
+ }
319
+ /** The chosen source/target anchor pair. */
320
+ interface ClosestAnchors {
321
+ source: HandleAnchor;
322
+ target: HandleAnchor;
323
+ }
324
+ /**
325
+ * Picks the source/target pair of **rendered handles** with the shortest
326
+ * straight-line distance between them.
327
+ *
328
+ * Returns `undefined` when either side has no candidates, so the caller can
329
+ * fall back to rectangle geometry for a node whose handles have not been
330
+ * measured yet (React Flow populates `handleBounds` on its first measurement
331
+ * pass; before that there is nothing to anchor to).
332
+ */
333
+ declare function pickClosestAnchors(sources: HandleAnchor[], targets: HandleAnchor[]): ClosestAnchors | undefined;
103
334
  /** Absolute coordinate of a handle on the given side (the side's midpoint). */
104
335
  declare function handlePoint(rect: NodeRect, side: FlowHandleSide): {
105
336
  x: number;
106
337
  y: number;
107
338
  };
108
- /** The chosen source/target handle pair plus their absolute anchor points. */
339
+ /** The side pair picked by {@link pickClosestHandles}, with both anchor points. */
109
340
  interface ClosestHandles {
110
341
  sourceSide: FlowHandleSide;
111
342
  targetSide: FlowHandleSide;
@@ -115,9 +346,14 @@ interface ClosestHandles {
115
346
  ty: number;
116
347
  }
117
348
  /**
118
- * Picks the source/target handle pair (one handle per node) with the shortest
119
- * straight-line distance between their anchor points. Empty side lists fall
120
- * back to all four sides so an edge always resolves to a pair.
349
+ * Rectangle-only fallback: picks the closest pair of **side midpoints** from the
350
+ * candidate sides of each node.
351
+ *
352
+ * `FlowSmartEdge` prefers {@link pickClosestAnchors} (measured handle centres)
353
+ * and only reaches for this before React Flow has measured the nodes. Pass the
354
+ * sides each node genuinely renders a handle on — passing sides that carry no
355
+ * handle produces an anchor floating on a bare border, which is the defect this
356
+ * module exists to avoid.
121
357
  */
122
358
  declare function pickClosestHandles(source: NodeRect, sourceSides: FlowHandleSide[], target: NodeRect, targetSides: FlowHandleSide[]): ClosestHandles;
123
359
 
@@ -229,6 +465,22 @@ interface FlowLayoutOptions {
229
465
  interface FlowLayoutResult<NodeType extends Node = Node, EdgeType extends Edge = Edge> {
230
466
  nodes: NodeType[];
231
467
  edges: EdgeType[];
468
+ /**
469
+ * Ids of edges that run **against** the layout direction — a rework / retry
470
+ * loop in a process graph. dagre breaks cycles by reversing such edges
471
+ * internally and never surfaces which ones it reversed, so this is derived
472
+ * from the ranks dagre stamps on the laid-out graph: an edge whose source
473
+ * ranks at or after its target went backwards. Render these with
474
+ * `FlowWeightedEdge`'s `variant="back"`.
475
+ */
476
+ backEdges: string[];
477
+ /**
478
+ * Ids of edges whose `source === target`. dagre does not lay out self-loops,
479
+ * so they are withheld from the graph entirely (never `setEdge`-ed) and are
480
+ * returned unchanged in `edges` — they take part in no rank computation and
481
+ * cannot distort the layout. Render these with `FlowSelfLoopEdge`.
482
+ */
483
+ selfLoops: string[];
232
484
  }
233
485
  /**
234
486
  * Pure dagre-powered auto layout. Computes new `position`s for `nodes` given
@@ -240,6 +492,11 @@ interface FlowLayoutResult<NodeType extends Node = Node, EdgeType extends Edge =
240
492
  * `node.height`, then a sensible default. Node identity and `data` are left
241
493
  * untouched — only `position` changes.
242
494
  *
495
+ * Also reports the graph's two structural signals — `backEdges` (edges that run
496
+ * against the layout direction) and `selfLoops` (`source === target`). Both are
497
+ * additive fields on the result; a caller that only destructures
498
+ * `{ nodes, edges }` is unaffected.
499
+ *
243
500
  * Pair with `useFlowLayout` to apply the result to a live canvas.
244
501
  */
245
502
  declare function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(nodes: NodeType[], edges: EdgeType[], options?: FlowLayoutOptions): FlowLayoutResult<NodeType, EdgeType>;
@@ -268,8 +525,8 @@ interface UseFlowLayoutResult {
268
525
  declare function useFlowLayout<NodeType extends Node = Node, EdgeType extends Edge = Edge>(): UseFlowLayoutResult;
269
526
 
270
527
  /** Which generic graph-geometry algorithm `layoutGraph` should run. */
271
- type LayoutAlgorithm = "concentric" | "force" | "layered-lr" | "grid";
272
- /** Horizontal/vertical gap used by the `"grid"` and `"layered-lr"` algorithms. */
528
+ type LayoutAlgorithm = "concentric" | "force" | "layered-lr" | "layered-tb" | "grid";
529
+ /** Horizontal/vertical gap used by the `"grid"` and `"layered-*"` algorithms. */
273
530
  interface LayoutSpacing {
274
531
  x: number;
275
532
  y: number;
@@ -286,7 +543,7 @@ interface LayoutOptions {
286
543
  ringRadius?: number;
287
544
  /** `"force"` only — number of synchronous simulation ticks to run. @default 300 */
288
545
  iterations?: number;
289
- /** `"grid"` / `"layered-lr"` — gap between nodes. @default {x:200,y:120} for grid. */
546
+ /** `"grid"` / `"layered-lr"` / `"layered-tb"` — gap between nodes. @default {x:200,y:120} for grid. */
290
547
  spacing?: LayoutSpacing;
291
548
  /** Resolve a node's size by id. Falls back to `measured`/`width`/`height`/a sensible default. */
292
549
  nodeSize?: (id: string) => {
@@ -303,7 +560,10 @@ interface LayoutOptions {
303
560
  * Node identity and `data` are left untouched — only `position` changes.
304
561
  * Pair with `useAutoLayout` for a memoized hook form.
305
562
  *
306
- * - `"layered-lr"` delegates to the dagre-powered `layoutFlow` (direction `"LR"`).
563
+ * - `"layered-lr"` / `"layered-tb"` delegate to the dagre-powered `layoutFlow`
564
+ * (direction `"LR"` / `"TB"`). `spacing.x` is always the horizontal gap and
565
+ * `spacing.y` the vertical one, so the two differ only in which axis carries
566
+ * the ranks.
307
567
  * - `"concentric"` places `centerId` (or the highest-degree node) at the
308
568
  * origin, with BFS shells at `ring × ringRadius`; disconnected nodes land in
309
569
  * one extra outer ring so positions are never `NaN`.
@@ -477,13 +737,53 @@ interface LegendItem {
477
737
  /** Any CSS color or token reference, e.g. "var(--chart-1)". */
478
738
  color: string;
479
739
  }
480
- interface LegendProps {
740
+ /**
741
+ * Categorical legend props — a swatch-and-label list. This is the shape
742
+ * `Legend` renders when `variant` is omitted, byte-for-byte unchanged from
743
+ * before the `"scale"` variant existed.
744
+ */
745
+ interface LegendCategoricalProps {
746
+ variant?: "categorical";
481
747
  items: LegendItem[];
482
748
  title?: string;
483
749
  className?: string;
484
750
  }
485
- /** Small legend mapping colors/types to labels for a canvas or chart. */
486
- declare function Legend({ items, title, className }: LegendProps): react.JSX.Element;
751
+ /**
752
+ * Continuous scale legend a reading key that explains a WIDTH or COLOR
753
+ * encoding as a *range*, not a set of discrete categories (e.g.
754
+ * `FlowWeightedEdge`'s `data.weight` → stroke width, `data.value` → stroke
755
+ * color). `kind: "width"` reuses `computeEdgeWeightScale` — the exact pure
756
+ * scale `FlowWeightedEdge` calls — so the sample strokes drawn here can never
757
+ * drift from the widths a real flow's edges render for the same weights.
758
+ */
759
+ interface LegendScaleProps {
760
+ variant: "scale";
761
+ /** Which continuous encoding this legend explains. */
762
+ kind: "width" | "color";
763
+ /** `[min, max]` of the underlying value the ramp represents. */
764
+ domain: [number, number];
765
+ /** Formats a domain value for display at a tick. @default `(v) => v.toLocaleString()` */
766
+ format?: (value: number) => string;
767
+ /**
768
+ * Sample count for `kind: "width"`: `"minmax"` draws a min/max pair of
769
+ * sample strokes, `"minmedmax"` adds the domain midpoint as a third
770
+ * sample. `kind: "color"` always renders a fixed 5-stop gradient
771
+ * regardless of this prop — five stops is what makes a 2-endpoint color
772
+ * ramp legible as an ordered scale (see the color-ramp a11y note below),
773
+ * independent of how many width samples are shown.
774
+ * @default "minmax"
775
+ */
776
+ ticks?: "minmax" | "minmedmax";
777
+ title?: string;
778
+ className?: string;
779
+ }
780
+ type LegendProps = LegendCategoricalProps | LegendScaleProps;
781
+ /**
782
+ * Small legend mapping colors/types to labels for a canvas or chart
783
+ * (`variant: "categorical"`, the default), or — via `variant="scale"` — a
784
+ * continuous width/color ramp with a domain, tick marks and formatting.
785
+ */
786
+ declare function Legend(props: LegendProps): react.JSX.Element;
487
787
 
488
788
  interface ZoomControlsProps {
489
789
  position?: PanelPosition;
@@ -631,4 +931,418 @@ interface UseFlowGroupsResult {
631
931
  */
632
932
  declare function useFlowGroups<NodeType extends Node = Node, EdgeType extends Edge = Edge>(): UseFlowGroupsResult;
633
933
 
634
- export { type BrandFlowButtonEdge, type BrandFlowGroupNode, type BrandFlowNode, type BrandFlowPlaceholderNode, CanvasShell, type CanvasShellProps, type ClosestHandles, type EdgeParams, FLOW_ALL_SIDE_HANDLES, FLOW_GROUP_NODE_TYPE, type FloatingEdgeData, type FloatingNodeGeometry, FlowButtonEdge, type FlowButtonEdgeData, FlowEdge, FlowFloatingEdge, FlowGroupNode, type FlowGroupNodeData, type FlowGroupOperationResult, type FlowGroupProxyEdgeData, type FlowGroupTone, type FlowHandleSide, type FlowLayoutDirection, type FlowLayoutOptions, type FlowLayoutResult, FlowMiniMap, type FlowMiniMapProps, FlowNode, type FlowNodeData, type FlowNodeHandles, FlowPlaceholderNode, type FlowPlaceholderNodeData, FlowSmartEdge, type GroupNodesOptions, type GroupOptions, HANDLE_SIDES, type HelperLineRect, HelperLines, type HelperLinesProps, type HelperLinesResult, InspectorPanel, type InspectorPanelProps, type LayoutAlgorithm, type LayoutOptions, type LayoutSpacing, Legend, type LegendItem, type LegendProps, type NodeRect, type UseFlowGroupsResult, type UseFlowLayoutResult, type UseHelperLinesOptions, type UseHelperLinesResult, ZoomControls, type ZoomControlsProps, collapseGroup, expandGroup, getEdgeParams, getHelperLines, groupNodes, handlePoint, isFlowGroupProxyEdge, layoutFlow, layoutGraph, pickClosestHandles, sideToPosition, toggleGroupCollapsed, ungroup, useAutoLayout, useFlowGroups, useFlowLayout, useHelperLines };
934
+ interface EdgeLabelPillProps extends ComponentPropsWithoutRef<"button"> {
935
+ /** Primary label, e.g. a frequency count ("128×"). */
936
+ label?: string;
937
+ /** Secondary label rendered alongside the primary, e.g. a duration ("3.4d avg"). */
938
+ secondaryLabel?: string;
939
+ /** Label anchor, from `getBezierPath`/`getSmoothStepPath`'s `labelX`/`labelY`. */
940
+ x: number;
941
+ y: number;
942
+ /** Matches the parent edge's `selected` state. */
943
+ selected?: boolean;
944
+ }
945
+ /**
946
+ * A small HTML pill (via `EdgeLabelRenderer`, not SVG `<text>`) anchored at an
947
+ * edge's label point — so it can theme, wrap, and carry two values, unlike a
948
+ * bare SVG text node. Renders nothing when neither label is set. Real
949
+ * `<button>` so it is a genuine keyboard tab stop with a visible focus ring;
950
+ * `pointer-events: auto` on an otherwise `nodrag nopan` wrapper so it doesn't
951
+ * drag/pan the canvas, and doesn't block hovering the edge underneath it (the
952
+ * wrapper is sized to the pill itself, not the whole edge).
953
+ *
954
+ * `className`/`...props` spread onto the root `<button>` (`data-slot="edge-label-pill"`)
955
+ * so a consumer that composes this pill from outside `@elabs-ai/components-flow` —
956
+ * `@elabs-ai/components-process`'s `ProcessTransitionEdge` is the reference caller —
957
+ * can reach it directly (a dashed frame, a `data-selection` attribute) without a new
958
+ * semantic prop on this component. `className` merges LAST via `cn()`, so a caller can
959
+ * override any of the pill's own utility classes; omitting both leaves every existing
960
+ * caller's rendered markup unchanged.
961
+ */
962
+ declare function EdgeLabelPill({ label, secondaryLabel, x, y, selected, className, ...props }: EdgeLabelPillProps): react.JSX.Element | null;
963
+
964
+ interface FlowWeightedEdgeData extends Record<string, unknown> {
965
+ /** Frequency/volume this edge carries. Scaled into stroke width — see `computeEdgeWeightScale`. */
966
+ weight?: number;
967
+ /** Edges sharing a `scaleGroup` share one min-max width domain. @default all edges in the flow */
968
+ scaleGroup?: string;
969
+ /** A second, continuous measure (e.g. average duration). Colours the stroke — needs `valueDomain` too. */
970
+ value?: number;
971
+ /** `[min, max]` domain `value` is interpolated across, from `--flow-edge-weak` to `--flow-edge-strong`. */
972
+ valueDomain?: [number, number];
973
+ /** Primary edge-label-pill text, e.g. a frequency count. */
974
+ label?: string;
975
+ /** Secondary edge-label-pill text, e.g. a duration. */
976
+ secondaryLabel?: string;
977
+ /** Path geometry. Ignored when `variant` is `"back"`, which always routes smoothstep. @default "bezier" */
978
+ path?: "bezier" | "smoothstep";
979
+ /**
980
+ * Whether this edge advances the process (`"forward"`) or runs against the
981
+ * layout direction (`"back"` — a rework/retry edge, as reported by
982
+ * `layoutFlow`'s `backEdges`).
983
+ *
984
+ * `"back"` is distinguished by SHAPE, not colour: a dashed stroke, and a
985
+ * smoothstep route pushed clear of the forward edge between the same pair of
986
+ * nodes so the two never overlap. It also carries a real accessible name, so
987
+ * the direction reaches assistive technology as text rather than only as a
988
+ * `data-variant` attribute.
989
+ *
990
+ * @default "forward"
991
+ */
992
+ variant?: "forward" | "back";
993
+ /**
994
+ * Overrides the accessible name given to a `"back"` edge's graphic. Defaults
995
+ * to "Back edge — runs against the process direction".
996
+ */
997
+ variantLabel?: string;
998
+ /**
999
+ * Passed straight through to the rendered `EdgeLabelPill`'s `className`/`...props`
1000
+ * (see `EdgeLabelPillProps`) — the seam a composing package (e.g.
1001
+ * `@elabs-ai/components-process`'s `ProcessTransitionEdge`) uses to reach the pill's
1002
+ * own root button from outside this component, without a new semantic prop here.
1003
+ */
1004
+ labelProps?: Omit<EdgeLabelPillProps, "label" | "secondaryLabel" | "x" | "y" | "selected">;
1005
+ }
1006
+ type BrandFlowWeightedEdge = Edge<FlowWeightedEdgeData, "weighted">;
1007
+ /**
1008
+ * Branded weighted edge: `data.weight` scales stroke width (min-maxed per
1009
+ * `data.scaleGroup` across every edge in the flow — see `computeEdgeWeightScale`,
1010
+ * exported so a sibling like a continuous `Legend` renders the same ramp);
1011
+ * `data.value` + `data.valueDomain` interpolate stroke colour between
1012
+ * `--flow-edge-weak` and `--flow-edge-strong`; `data.label`/`data.secondaryLabel`
1013
+ * render as an `EdgeLabelPill`. An edge with none of this data renders exactly
1014
+ * like `FlowEdge` (fixed 1.5px, `--flow-edge` token) — fully backward-compatible.
1015
+ * Register it in `edgeTypes={{ weighted: FlowWeightedEdge }}` and create edges
1016
+ * with `type: "weighted"` and `data: FlowWeightedEdgeData`.
1017
+ *
1018
+ * `weight`/`value` are VISUAL ONLY — stroke width and colour reach no screen
1019
+ * reader. This component cannot set its own accessible name: React Flow's
1020
+ * `EdgeWrapper` sources it from `edge.ariaLabel` on the edge OBJECT, one level
1021
+ * above the component it renders as a child (issue #285). Run your `edges`
1022
+ * through `withWeightedEdgeAria` (from `./edge-aria`, or set `edge.ariaLabel`
1023
+ * yourself) before handing them to `<CanvasShell>`/`<ReactFlow>` — otherwise
1024
+ * the measure this component exists to show reaches no assistive technology,
1025
+ * and the edge announces only React Flow's generic "Edge from n1 to n2".
1026
+ * `withWeightedEdgeAria` returns a new array each call — memoize it
1027
+ * (`useMemo(() => withWeightedEdgeAria(edges), [edges])`) if you call it
1028
+ * inline in render.
1029
+ *
1030
+ * `data-weight`/`data-value` are also stamped onto the rendered `<path>` (raw
1031
+ * `data.weight`/`data.value`, not the scaled stroke width) — a stable
1032
+ * selector for tests/consumers, independent of the naming seam above.
1033
+ *
1034
+ * `data.variant: "back"` marks an edge that runs against the process direction
1035
+ * (dagre's reversed edges — see `layoutFlow`'s `backEdges`). It is dashed and
1036
+ * routed clear of the forward edge between the same two nodes, and carries a
1037
+ * real accessible name; the default `"forward"` renders exactly as before.
1038
+ *
1039
+ * Selected state uses `--ring` (matching the `ring-ring` treatment `FlowNode`/
1040
+ * `FlowGroupNode` use), overriding weight/value-derived width and colour so a
1041
+ * selected edge always reads clearly. No stroke-dasharray animation — reduced
1042
+ * motion is respected because there is no motion to reduce.
1043
+ *
1044
+ * KEYBOARD FOCUS is a separate state, drawn by `FlowEdgePath` (#286): selection
1045
+ * needs a consumer's `onEdgesChange` to ever become true, so it can never be the
1046
+ * indicator a tab stop owes its user.
1047
+ */
1048
+ declare function FlowWeightedEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, markerEnd, style, selected, data, }: EdgeProps<BrandFlowWeightedEdge>): react.JSX.Element;
1049
+
1050
+ /**
1051
+ * Where a back edge's return leg runs, so it stays VISIBLE.
1052
+ *
1053
+ * A back edge is drawn as a smoothstep with one long cross-segment running against the
1054
+ * layout direction. React Flow puts that segment halfway between the two handles by
1055
+ * default, and a fixed nudge off the midpoint does not help: in a top-down layout the
1056
+ * midpoint of a rework edge sits between two ranks, i.e. squarely inside the column of
1057
+ * cards it is supposed to run past. Edges paint UNDER nodes, so the segment vanishes
1058
+ * behind the cards and all the reader is left with is a dashed stub below one node and
1059
+ * another above the other — which reads as a stray rectangle, not as a loop back.
1060
+ *
1061
+ * So the leg is placed OUTSIDE every card it would otherwise pass behind: past the far
1062
+ * side of every node whose extent overlaps the span the edge crosses. That is how a
1063
+ * process-mining tool draws a rework loop — out, around the rank, back in — and it is
1064
+ * the only placement that is correct for a graph rather than for a pair of nodes.
1065
+ */
1066
+ /** One node's laid-out box, in flow coordinates. */
1067
+ interface BackEdgeNodeRect {
1068
+ x: number;
1069
+ y: number;
1070
+ width: number;
1071
+ height: number;
1072
+ }
1073
+ /** Which axis the layout's ranks advance along. */
1074
+ type BackEdgeAxis = "vertical" | "horizontal";
1075
+ /**
1076
+ * The `centerX` (vertical layout) or `centerY` (horizontal layout) to hand
1077
+ * `getSmoothStepPath`, or `null` when nothing is measured yet and React Flow's own
1078
+ * midpoint should stand.
1079
+ *
1080
+ * @param rects Every laid-out node on the canvas. Nodes that cannot be hit are filtered
1081
+ * out here rather than by the caller, so the caller stays a plain map.
1082
+ * @param span The interval the edge crosses on the RANK axis (`[min, max]` of the two
1083
+ * handle coordinates) — a node outside it is never behind this edge.
1084
+ * @param clearance Gap left between the leg and the outermost card it clears.
1085
+ */
1086
+ declare function backEdgeDetour(rects: readonly BackEdgeNodeRect[], axis: BackEdgeAxis, span: readonly [number, number], clearance: number): number | null;
1087
+
1088
+ /**
1089
+ * weight-scale — pure, framework-free "edge weight → stroke width" scale.
1090
+ *
1091
+ * `FlowWeightedEdge` calls this with every edge in the flow (via `useEdges()`,
1092
+ * memoized) so a group of edges can be min-maxed against ONE shared domain
1093
+ * instead of each edge picking its own arbitrary width. No React, no DOM: a
1094
+ * sibling (e.g. RM-045's continuous `Legend`) can call it directly to render
1095
+ * the same ramp the edges use, and it is trivially unit-testable.
1096
+ */
1097
+ /** Minimal edge shape the scale needs — a subset of `Edge<FlowWeightedEdgeData>`. */
1098
+ interface WeightedEdgeLike {
1099
+ id: string;
1100
+ data?: {
1101
+ weight?: number;
1102
+ scaleGroup?: string;
1103
+ };
1104
+ }
1105
+ interface EdgeWeightScaleOptions {
1106
+ /** Output stroke-width range, in px. @default [1.5, 8] */
1107
+ widthRange?: [number, number];
1108
+ /**
1109
+ * Restrict the domain calculation (and the returned map) to edges whose
1110
+ * `data.scaleGroup` equals this value — edges in a different group are
1111
+ * skipped entirely. Omit to compute one scale per distinct `scaleGroup`
1112
+ * present in `edges` (edges with no `scaleGroup` share one implicit
1113
+ * default group, so a flow that never sets it still gets one shared
1114
+ * domain — "all edges in the same `<ReactFlow>`").
1115
+ */
1116
+ scaleGroup?: string;
1117
+ }
1118
+ /** Matches today's fixed 1.5px `FlowEdge` stroke, so an unweighted edge is unchanged. */
1119
+ declare const DEFAULT_EDGE_WIDTH_RANGE: [number, number];
1120
+ /**
1121
+ * Resolve every edge's `data.weight` into a stroke-width, linearly min-maxed
1122
+ * into `widthRange` per `scaleGroup`. An edge with no `data.weight` gets the
1123
+ * range floor — the existing fixed 1.5px `FlowEdge` already draws, so a plain
1124
+ * edge renders unchanged. An edge that is the only member of its group (or
1125
+ * whose group has zero weight variance) gets the midpoint of the range —
1126
+ * there is no domain to compare it against.
1127
+ */
1128
+ declare function computeEdgeWeightScale(edges: WeightedEdgeLike[], opts?: EdgeWeightScaleOptions): Map<string, number>;
1129
+
1130
+ /**
1131
+ * edge-aria — pure, framework-free "edge data → accessible name" naming seam
1132
+ * for `FlowWeightedEdge`, mirroring `weight-scale.ts`'s shape (no React, no
1133
+ * DOM, trivially unit-testable).
1134
+ *
1135
+ * `FlowWeightedEdge` encodes `data.weight` as stroke width and, optionally,
1136
+ * `data.value` as stroke colour — both purely visual. React Flow's own
1137
+ * `EdgeWrapper` sources an edge's accessible name from `edge.ariaLabel`
1138
+ * (`edge` object, not the edge COMPONENT — see issue #285), so the component
1139
+ * has no channel to set it. This module is the seam a caller runs their
1140
+ * `edges` array through instead.
1141
+ *
1142
+ * Naming contract (a decision, not an accident):
1143
+ * 1. An explicit `edge.ariaLabel` always wins — never overwritten.
1144
+ * 2. Otherwise compose `"Edge from <source> to <target>, weight <n>"`,
1145
+ * appending `", <valueLabel> <n>"` when `data.value` is set and the pill
1146
+ * text (`data.label`/`data.secondaryLabel`, space-joined) when either is
1147
+ * present.
1148
+ * 3. When an edge carries none of `weight`/`value`/`label`/`secondaryLabel`,
1149
+ * return `undefined` so React Flow's own default
1150
+ * ("Edge from <source> to <target>") survives untouched. That quoted
1151
+ * string is `@xyflow/react`'s output, not ours — the `e-bare` edge in
1152
+ * `flow-weighted-edge.stories.tsx`'s `NamingContractEdges` story (#327)
1153
+ * is the browser lock for it: it renders a measure-less edge against a
1154
+ * real `CanvasShell` and asserts the default name via
1155
+ * `toHaveAccessibleName`. If that story goes red after a dependency
1156
+ * bump, read it as a signal that React Flow's default changed, not as a
1157
+ * flake to silence.
1158
+ */
1159
+
1160
+ interface WeightedEdgeAriaOptions {
1161
+ /** Word used for the width measure in the composed name. @default "weight" */
1162
+ weightLabel?: string;
1163
+ /** Word used for the colour measure in the composed name. @default "value" */
1164
+ valueLabel?: string;
1165
+ /**
1166
+ * Map a node id to its display name, so the composed name says
1167
+ * "Edge from Order placed to Picked" rather than "Edge from n1 to n2". The
1168
+ * caller owns node display names (this module never reaches into a `nodes`
1169
+ * array), so it is a hook, not an automatic lookup.
1170
+ */
1171
+ nameOf?: (nodeId: string) => string;
1172
+ /** Format a numeric measure for display. @default String(n) */
1173
+ formatNumber?: (n: number) => string;
1174
+ }
1175
+ /**
1176
+ * Derive one edge's accessible name from its weight/value/labels. Pure — no
1177
+ * React, no DOM. Returns:
1178
+ * - the edge's own `ariaLabel`, unchanged, when the caller already set one;
1179
+ * - a composed string when the edge carries `weight`, `value`, `label` or
1180
+ * `secondaryLabel`;
1181
+ * - `undefined` when there is nothing to add (so the default announcement
1182
+ * from React Flow's `EdgeWrapper` survives untouched).
1183
+ */
1184
+ declare function buildWeightedEdgeAriaLabel(edge: BrandFlowWeightedEdge, opts?: WeightedEdgeAriaOptions): string | undefined;
1185
+ /**
1186
+ * Stamp `ariaLabel` onto every weighted edge that has none, per
1187
+ * `buildWeightedEdgeAriaLabel`'s naming contract. An edge that already
1188
+ * carries an `ariaLabel` is returned unchanged (never overwritten); an edge
1189
+ * with nothing to add is also returned unchanged, so an unrelated edge type
1190
+ * sharing this array keeps its identity.
1191
+ *
1192
+ * Returns a NEW array — memoize the call when used inline in render (e.g.
1193
+ * `useMemo(() => withWeightedEdgeAria(edges), [edges])`), or a consumer hands
1194
+ * React Flow a new `edges` identity every frame.
1195
+ */
1196
+ declare function withWeightedEdgeAria<E extends BrandFlowWeightedEdge>(edges: E[], opts?: WeightedEdgeAriaOptions): E[];
1197
+
1198
+ interface FlowSelfLoopEdgeData extends Record<string, unknown> {
1199
+ /**
1200
+ * Frequency/volume this loop carries. Scaled into stroke width by the SAME
1201
+ * `computeEdgeWeightScale` domain as `FlowWeightedEdge`, so a loop's weight
1202
+ * is directly comparable with the forward edges around it.
1203
+ */
1204
+ weight?: number;
1205
+ /** Edges sharing a `scaleGroup` share one min-max width domain. @default all edges in the flow */
1206
+ scaleGroup?: string;
1207
+ /** Primary edge-label-pill text, e.g. a repeat count. */
1208
+ label?: string;
1209
+ /** Secondary edge-label-pill text, e.g. an average duration. */
1210
+ secondaryLabel?: string;
1211
+ /** Radius of the arc, in px. @default 28 */
1212
+ loopRadius?: number;
1213
+ /**
1214
+ * Overrides the accessible name given to the loop's graphic. Defaults to
1215
+ * "Self-loop on <node> — this step repeats".
1216
+ */
1217
+ loopLabel?: string;
1218
+ /**
1219
+ * Passed straight through to the rendered `EdgeLabelPill`'s `className`/`...props`
1220
+ * (see `EdgeLabelPillProps`) — the seam a composing package (e.g.
1221
+ * `@elabs-ai/components-process`'s `ProcessTransitionEdge`) uses to reach the pill's
1222
+ * own root button from outside this component, without a new semantic prop here.
1223
+ */
1224
+ labelProps?: Omit<EdgeLabelPillProps, "label" | "secondaryLabel" | "x" | "y" | "selected">;
1225
+ }
1226
+ type BrandFlowSelfLoopEdge = Edge<FlowSelfLoopEdgeData, "self-loop">;
1227
+ /**
1228
+ * Branded self-loop edge: an edge whose `source === target` — the "this step
1229
+ * repeated" signal of a process map. Register it in
1230
+ * `edgeTypes={{ "self-loop": FlowSelfLoopEdge }}` and create edges with
1231
+ * `type: "self-loop"` and `data: FlowSelfLoopEdgeData`.
1232
+ *
1233
+ * dagre cannot lay a self-loop out, so `layoutFlow` withholds them from the
1234
+ * graph entirely and reports their ids in `selfLoops` — this component draws
1235
+ * the arc itself, from the node's own live geometry (`useInternalNode`).
1236
+ *
1237
+ * ## It terminates ON the handle dots, like every other brand edge
1238
+ *
1239
+ * The two handle points of a self-loop sit on OPPOSITE sides of one node, so they
1240
+ * describe no useful straight line — which is why this edge used to ignore them and
1241
+ * arc over the node's top edge instead, with its feet on bare border a loop-radius
1242
+ * away from the nearest dot. Measured on the process map, that read as a detached
1243
+ * arc floating above the card: 24 px clear of any dot in a top-to-bottom layout, and
1244
+ * 69 px in a left-to-right one, where the loop stayed stubbornly on TOP while the
1245
+ * flow (and the handles) had moved to the sides. Both are the "an edge terminates on
1246
+ * a handle dot" rule being broken, just by a component that had declared itself
1247
+ * exempt.
1248
+ *
1249
+ * `selfLoopHandleArc` keeps the loop a SHAPE — it just bulges clear of the node on
1250
+ * the side a quarter turn from the source handle's own normal, so it lassos down the
1251
+ * right in a top-to-bottom layout and arcs over the top in a left-to-right one,
1252
+ * without this component knowing which direction is in force. Before the node is
1253
+ * measured there is nothing to clear, so it falls back to the node-box arc
1254
+ * (`selfLoopPath`) rather than to `NaN`.
1255
+ *
1256
+ * The loop is distinguished from a forward edge by its SHAPE, not by colour —
1257
+ * a closed arc above the node, legible in greyscale and in every theme — and
1258
+ * publishes that meaning as a real accessible name, because a `data-slot` is
1259
+ * invisible to assistive technology. Its label is an `EdgeLabelPill` at the
1260
+ * arc's apex, a genuine keyboard tab stop with a visible focus ring.
1261
+ *
1262
+ * Stroke width comes from the same `computeEdgeWeightScale` domain
1263
+ * `FlowWeightedEdge` uses, so a loop weighted 8 reads as thick as a forward
1264
+ * edge weighted 8. Nothing animates, so there is no motion to reduce.
1265
+ *
1266
+ * The arc is drawn through `FlowEdgePath`, so it inherits the shared keyboard
1267
+ * focus indicator (#286) rather than having to opt into it.
1268
+ */
1269
+ declare function FlowSelfLoopEdge({ id, source, sourceX, sourceY, targetX, targetY, markerEnd, style, selected, data, }: EdgeProps<BrandFlowSelfLoopEdge>): react.JSX.Element;
1270
+
1271
+ /**
1272
+ * self-loop-geometry — pure, framework-free arc math for a self-referencing edge.
1273
+ *
1274
+ * Kept out of the component (and out of React) so the loop's shape can be
1275
+ * unit-tested without a canvas, and so a sibling that needs the same apex
1276
+ * point (a legend, an overlay, a screenshot harness) can compute it directly.
1277
+ */
1278
+ /** Default arc radius, in px. A loop this size clears `FlowNode`'s header without dominating it. */
1279
+ declare const DEFAULT_LOOP_RADIUS = 28;
1280
+ /** The box a self-loop is drawn above. Matches a React Flow `InternalNode` structurally. */
1281
+ interface SelfLoopAnchor {
1282
+ /** Horizontal centre of the node the loop belongs to. */
1283
+ centerX: number;
1284
+ /** Top edge of that node — the loop is drawn above this line. */
1285
+ topY: number;
1286
+ }
1287
+ interface SelfLoopPath {
1288
+ /** SVG `d` for the arc. */
1289
+ path: string;
1290
+ /** Where a label belongs: the arc's apex. */
1291
+ labelX: number;
1292
+ labelY: number;
1293
+ }
1294
+ /**
1295
+ * A cubic arc that leaves the node's top-RIGHT, bulges up over the node, and
1296
+ * re-enters at its top-LEFT — the conventional "this step repeated" mark in a
1297
+ * process map, and a SHAPE rather than a colour, so it survives greyscale.
1298
+ *
1299
+ * The curve is symmetric about `centerX`, which puts its `t = 0.5` midpoint
1300
+ * exactly at `(centerX, topY - APEX_FACTOR × loopRadius)` — the apex the label
1301
+ * is anchored to. Never returns `NaN`: a non-finite input falls back to `0`
1302
+ * and a non-positive radius falls back to the default.
1303
+ */
1304
+ declare function selfLoopPath(anchor: SelfLoopAnchor, loopRadius: number): SelfLoopPath;
1305
+ /**
1306
+ * The two handle anchors a self-loop actually joins, plus the box it has to clear.
1307
+ *
1308
+ * A self-loop's `sourceX/sourceY` and `targetX/targetY` are the SAME node's two handle
1309
+ * points, which is why they describe no useful straight line — but they are still the
1310
+ * only two points on the canvas the reader recognises as connectors, so the arc has to
1311
+ * start and end exactly on them. {@link selfLoopHandleArc} is the geometry that does
1312
+ * that; {@link selfLoopPath} remains the node-box arc used before a node is measured.
1313
+ */
1314
+ interface SelfLoopHandleAnchor {
1315
+ /** Where the loop leaves the node — React Flow's own SOURCE handle anchor. */
1316
+ sourceX: number;
1317
+ sourceY: number;
1318
+ /** Where it re-enters — React Flow's own TARGET handle anchor. */
1319
+ targetX: number;
1320
+ targetY: number;
1321
+ /** The node's centre. Decides which way is "out of the card". */
1322
+ centerX: number;
1323
+ centerY: number;
1324
+ /** The node's rendered size, so the arc clears the card instead of crossing it. */
1325
+ width: number;
1326
+ height: number;
1327
+ }
1328
+ /**
1329
+ * A cubic that leaves the SOURCE handle dot, bulges clear of the node on one side, and
1330
+ * re-enters at the TARGET handle dot.
1331
+ *
1332
+ * It is direction-agnostic by construction: the bulge is the source normal rotated a
1333
+ * quarter turn, so a top-to-bottom layout (handles on the bottom and top) gets a lasso
1334
+ * down the node's right-hand side, and a left-to-right layout (handles on the right and
1335
+ * left) gets an arc over the top — with no `direction` prop, and no list of cases to keep
1336
+ * in step with `layoutFlow`'s `HANDLE_BY_DIRECTION`.
1337
+ *
1338
+ * `loopRadius` is how far the curve shoots straight out of each dot before it turns, and
1339
+ * also the gap the arc's widest point keeps from the card's edge. The label sits at that
1340
+ * widest point — the cubic's `t = 0.5` midpoint — so it is off the node by construction.
1341
+ *
1342
+ * Never returns `NaN`: non-finite inputs fall back to `0`, a non-positive radius to
1343
+ * {@link DEFAULT_LOOP_RADIUS}, and a handle point sitting on the node's centre (nothing
1344
+ * measured yet) to a downward source normal.
1345
+ */
1346
+ declare function selfLoopHandleArc(anchor: SelfLoopHandleAnchor, loopRadius: number): SelfLoopPath;
1347
+
1348
+ export { type BackEdgeAxis, type BackEdgeNodeRect, type BrandFlowButtonEdge, type BrandFlowGroupNode, type BrandFlowNode, type BrandFlowPlaceholderNode, type BrandFlowSelfLoopEdge, type BrandFlowWeightedEdge, CanvasShell, type CanvasShellProps, type ClosestAnchors, type ClosestHandles, DEFAULT_EDGE_WIDTH_RANGE, DEFAULT_LOOP_RADIUS, EdgeLabelPill, type EdgeLabelPillProps, type EdgeParams, type EdgeWeightScaleOptions, FLOW_ALL_SIDE_HANDLES, FLOW_EDGE_FOCUS_CONTOUR_WIDTH, FLOW_EDGE_FOCUS_RING_WIDTH, FLOW_GROUP_NODE_TYPE, FLOW_HANDLE_ANCHOR_CLASS, type FloatingEdgeData, type FloatingNodeGeometry, FlowButtonEdge, type FlowButtonEdgeData, FlowEdge, FlowEdgePath, type FlowEdgePathProps, FlowFloatingEdge, FlowGroupNode, type FlowGroupNodeData, type FlowGroupOperationResult, type FlowGroupProxyEdgeData, type FlowGroupTone, type FlowHandleSide, type FlowLayoutDirection, type FlowLayoutOptions, type FlowLayoutResult, FlowMiniMap, type FlowMiniMapProps, FlowNode, type FlowNodeData, type FlowNodeHandles, FlowPlaceholderNode, type FlowPlaceholderNodeData, FlowSelfLoopEdge, type FlowSelfLoopEdgeData, FlowSmartEdge, FlowWeightedEdge, type FlowWeightedEdgeData, type GroupNodesOptions, type GroupOptions, HANDLE_SIDES, type HandleAnchor, type HelperLineRect, HelperLines, type HelperLinesProps, type HelperLinesResult, InspectorPanel, type InspectorPanelProps, type LayoutAlgorithm, type LayoutOptions, type LayoutSpacing, Legend, type LegendCategoricalProps, type LegendItem, type LegendProps, type LegendScaleProps, type NodeRect, type SelfLoopAnchor, type SelfLoopHandleAnchor, type SelfLoopPath, type UseFlowGroupsResult, type UseFlowLayoutResult, type UseHelperLinesOptions, type UseHelperLinesResult, type WeightedEdgeAriaOptions, type WeightedEdgeLike, ZoomControls, type ZoomControlsProps, backEdgeDetour, buildWeightedEdgeAriaLabel, collapseGroup, computeEdgeWeightScale, expandGroup, getEdgeParams, getHelperLines, groupNodes, handlePoint, isFlowGroupProxyEdge, layoutFlow, layoutGraph, pickClosestAnchors, pickClosestHandles, positionToSide, selfLoopHandleArc, selfLoopPath, sideToPosition, toggleGroupCollapsed, ungroup, useAutoLayout, useFlowGroups, useFlowLayout, useHelperLines, withWeightedEdgeAria };