@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
@@ -0,0 +1,308 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import {
3
+ Position,
4
+ getBezierPath,
5
+ getSmoothStepPath,
6
+ useEdges,
7
+ useStore,
8
+ type Edge,
9
+ type EdgeProps,
10
+ type ReactFlowState,
11
+ } from "@xyflow/react";
12
+ import { resolveTokenColor } from "@elabs-ai/components-tokens";
13
+ import { FlowEdgePath } from "../flow-edge-path";
14
+ import { EdgeLabelPill, type EdgeLabelPillProps } from "./edge-label-pill";
15
+ import { backEdgeDetour, type BackEdgeNodeRect } from "./back-edge-geometry";
16
+ import {
17
+ computeEdgeWeightScale,
18
+ DEFAULT_EDGE_WIDTH_RANGE,
19
+ type WeightedEdgeLike,
20
+ } from "./weight-scale";
21
+
22
+ /** Stable empty array for the forward branch of the node subscription below. */
23
+ const NO_NODES: ReactFlowState["nodes"] = [];
24
+
25
+ export interface FlowWeightedEdgeData extends Record<string, unknown> {
26
+ /** Frequency/volume this edge carries. Scaled into stroke width — see `computeEdgeWeightScale`. */
27
+ weight?: number;
28
+ /** Edges sharing a `scaleGroup` share one min-max width domain. @default all edges in the flow */
29
+ scaleGroup?: string;
30
+ /** A second, continuous measure (e.g. average duration). Colours the stroke — needs `valueDomain` too. */
31
+ value?: number;
32
+ /** `[min, max]` domain `value` is interpolated across, from `--flow-edge-weak` to `--flow-edge-strong`. */
33
+ valueDomain?: [number, number];
34
+ /** Primary edge-label-pill text, e.g. a frequency count. */
35
+ label?: string;
36
+ /** Secondary edge-label-pill text, e.g. a duration. */
37
+ secondaryLabel?: string;
38
+ /** Path geometry. Ignored when `variant` is `"back"`, which always routes smoothstep. @default "bezier" */
39
+ path?: "bezier" | "smoothstep";
40
+ /**
41
+ * Whether this edge advances the process (`"forward"`) or runs against the
42
+ * layout direction (`"back"` — a rework/retry edge, as reported by
43
+ * `layoutFlow`'s `backEdges`).
44
+ *
45
+ * `"back"` is distinguished by SHAPE, not colour: a dashed stroke, and a
46
+ * smoothstep route pushed clear of the forward edge between the same pair of
47
+ * nodes so the two never overlap. It also carries a real accessible name, so
48
+ * the direction reaches assistive technology as text rather than only as a
49
+ * `data-variant` attribute.
50
+ *
51
+ * @default "forward"
52
+ */
53
+ variant?: "forward" | "back";
54
+ /**
55
+ * Overrides the accessible name given to a `"back"` edge's graphic. Defaults
56
+ * to "Back edge — runs against the process direction".
57
+ */
58
+ variantLabel?: string;
59
+ /**
60
+ * Passed straight through to the rendered `EdgeLabelPill`'s `className`/`...props`
61
+ * (see `EdgeLabelPillProps`) — the seam a composing package (e.g.
62
+ * `@elabs-ai/components-process`'s `ProcessTransitionEdge`) uses to reach the pill's
63
+ * own root button from outside this component, without a new semantic prop here.
64
+ */
65
+ labelProps?: Omit<EdgeLabelPillProps, "label" | "secondaryLabel" | "x" | "y" | "selected">;
66
+ }
67
+
68
+ export type BrandFlowWeightedEdge = Edge<FlowWeightedEdgeData, "weighted">;
69
+
70
+ // Approximate hex fallbacks for `--flow-edge-weak`/`--flow-edge-strong`, used
71
+ // only when the CSS custom property can't be read (SSR, or the tokens
72
+ // stylesheet isn't loaded yet) — resolveTokenColor() reads the live theme
73
+ // value whenever a `document` is available.
74
+ const FALLBACK_WEAK = "#6085a1";
75
+ const FALLBACK_STRONG = "#496d89";
76
+
77
+ /**
78
+ * Shape channel for `variant="back"`: a dash pattern plus a reduced stroke
79
+ * opacity on the same `--flow-edge` token. Dashes are readable in greyscale
80
+ * and under any theme, so the back edge never depends on hue (WCAG 1.4.1).
81
+ */
82
+ const BACK_EDGE_DASHARRAY = "6 4";
83
+ const BACK_EDGE_OPACITY = 0.7;
84
+ /**
85
+ * Clearance, in px, between a back edge and everything it must stay clear OF.
86
+ *
87
+ * Two uses. As `getSmoothStepPath`'s `offset` it is how far the path runs straight out of
88
+ * a handle before it turns, which is what keeps it off the forward edge joining the same
89
+ * two nodes. As the gap in {@link backEdgeDetour} it is how far past the outermost card
90
+ * the return leg sits — see that function for why a card-relative placement, rather than
91
+ * a nudge off the midpoint, is the only one that stays visible.
92
+ */
93
+ const BACK_EDGE_CLEARANCE = 40;
94
+
95
+ const DEFAULT_BACK_EDGE_LABEL = "Back edge — runs against the process direction";
96
+
97
+ function clamp01(t: number): number {
98
+ return Math.min(1, Math.max(0, t));
99
+ }
100
+
101
+ function hexToRgb(hex: string): [number, number, number] {
102
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(hex);
103
+ if (!m) return [0, 0, 0];
104
+ return [parseInt(m[1]!, 16), parseInt(m[2]!, 16), parseInt(m[3]!, 16)];
105
+ }
106
+
107
+ function toHexByte(n: number): string {
108
+ return Math.round(Math.min(255, Math.max(0, n)))
109
+ .toString(16)
110
+ .padStart(2, "0");
111
+ }
112
+
113
+ /** Linear RGB interpolation between two hex colors — pure, no DOM. */
114
+ function mixHex(a: string, b: string, t: number): string {
115
+ const [ar, ag, ab] = hexToRgb(a);
116
+ const [br, bg, bb] = hexToRgb(b);
117
+ const r = ar + (br - ar) * t;
118
+ const g = ag + (bg - ag) * t;
119
+ const bl = ab + (bb - ab) * t;
120
+ return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(bl)}`;
121
+ }
122
+
123
+ /**
124
+ * Resolve `data.value`/`data.valueDomain` into a stroke color interpolated
125
+ * between the `--flow-edge-weak`/`--flow-edge-strong` tokens. Returns
126
+ * `undefined` when either input is missing, so the caller can fall back to
127
+ * the plain `--flow-edge` token (unweighted-looking) edge color.
128
+ */
129
+ function resolveValueStrokeColor(
130
+ value: number | undefined,
131
+ domain: [number, number] | undefined,
132
+ ): string | undefined {
133
+ if (value === undefined || !domain) return undefined;
134
+ const [lo, hi] = domain;
135
+ const t = hi === lo ? 1 : clamp01((value - lo) / (hi - lo));
136
+ const weak = resolveTokenColor("--flow-edge-weak", { fallback: FALLBACK_WEAK });
137
+ const strong = resolveTokenColor("--flow-edge-strong", { fallback: FALLBACK_STRONG });
138
+ return mixHex(weak, strong, t);
139
+ }
140
+
141
+ /**
142
+ * Branded weighted edge: `data.weight` scales stroke width (min-maxed per
143
+ * `data.scaleGroup` across every edge in the flow — see `computeEdgeWeightScale`,
144
+ * exported so a sibling like a continuous `Legend` renders the same ramp);
145
+ * `data.value` + `data.valueDomain` interpolate stroke colour between
146
+ * `--flow-edge-weak` and `--flow-edge-strong`; `data.label`/`data.secondaryLabel`
147
+ * render as an `EdgeLabelPill`. An edge with none of this data renders exactly
148
+ * like `FlowEdge` (fixed 1.5px, `--flow-edge` token) — fully backward-compatible.
149
+ * Register it in `edgeTypes={{ weighted: FlowWeightedEdge }}` and create edges
150
+ * with `type: "weighted"` and `data: FlowWeightedEdgeData`.
151
+ *
152
+ * `weight`/`value` are VISUAL ONLY — stroke width and colour reach no screen
153
+ * reader. This component cannot set its own accessible name: React Flow's
154
+ * `EdgeWrapper` sources it from `edge.ariaLabel` on the edge OBJECT, one level
155
+ * above the component it renders as a child (issue #285). Run your `edges`
156
+ * through `withWeightedEdgeAria` (from `./edge-aria`, or set `edge.ariaLabel`
157
+ * yourself) before handing them to `<CanvasShell>`/`<ReactFlow>` — otherwise
158
+ * the measure this component exists to show reaches no assistive technology,
159
+ * and the edge announces only React Flow's generic "Edge from n1 to n2".
160
+ * `withWeightedEdgeAria` returns a new array each call — memoize it
161
+ * (`useMemo(() => withWeightedEdgeAria(edges), [edges])`) if you call it
162
+ * inline in render.
163
+ *
164
+ * `data-weight`/`data-value` are also stamped onto the rendered `<path>` (raw
165
+ * `data.weight`/`data.value`, not the scaled stroke width) — a stable
166
+ * selector for tests/consumers, independent of the naming seam above.
167
+ *
168
+ * `data.variant: "back"` marks an edge that runs against the process direction
169
+ * (dagre's reversed edges — see `layoutFlow`'s `backEdges`). It is dashed and
170
+ * routed clear of the forward edge between the same two nodes, and carries a
171
+ * real accessible name; the default `"forward"` renders exactly as before.
172
+ *
173
+ * Selected state uses `--ring` (matching the `ring-ring` treatment `FlowNode`/
174
+ * `FlowGroupNode` use), overriding weight/value-derived width and colour so a
175
+ * selected edge always reads clearly. No stroke-dasharray animation — reduced
176
+ * motion is respected because there is no motion to reduce.
177
+ *
178
+ * KEYBOARD FOCUS is a separate state, drawn by `FlowEdgePath` (#286): selection
179
+ * needs a consumer's `onEdgesChange` to ever become true, so it can never be the
180
+ * indicator a tab stop owes its user.
181
+ */
182
+ export function FlowWeightedEdge({
183
+ id,
184
+ sourceX,
185
+ sourceY,
186
+ targetX,
187
+ targetY,
188
+ sourcePosition,
189
+ targetPosition,
190
+ markerEnd,
191
+ style,
192
+ selected,
193
+ data,
194
+ }: EdgeProps<BrandFlowWeightedEdge>) {
195
+ const edges = useEdges();
196
+ const widthByEdgeId = useMemo(
197
+ () => computeEdgeWeightScale(edges as unknown as WeightedEdgeLike[]),
198
+ [edges],
199
+ );
200
+
201
+ const variant = data?.variant ?? "forward";
202
+ const isBack = variant === "back";
203
+ // Only a BACK edge needs the node rects (to route its return leg around the cards it
204
+ // crosses). `useNodes()` would subscribe every edge to the whole nodes array, which the
205
+ // store replaces on each drag frame, selection and resize — so every forward edge on
206
+ // the canvas re-rendered on any node change anywhere. The forward branch hands back one
207
+ // frozen constant, which `useStore`'s default `Object.is` compares equal every time.
208
+ const nodes = useStore(
209
+ useCallback((s: ReactFlowState) => (isBack ? s.nodes : NO_NODES), [isBack]),
210
+ );
211
+ const pathType = data?.path ?? "bezier";
212
+
213
+ // Ranks advance vertically when the handles are on the top/bottom faces. Read from the
214
+ // TARGET side: `sourcePosition` on a self-connecting or hand-placed edge can disagree,
215
+ // and it is the incoming face that decides which way the last leg must approach from.
216
+ const axis =
217
+ targetPosition === Position.Top || targetPosition === Position.Bottom
218
+ ? "vertical"
219
+ : "horizontal";
220
+ const rects = useMemo<BackEdgeNodeRect[]>(() => {
221
+ if (!isBack) return [];
222
+ const out: BackEdgeNodeRect[] = [];
223
+ for (const node of nodes) {
224
+ // A child node's `position` is parent-relative, so it is not comparable with the
225
+ // absolute handle coordinates this edge is placed against. Skipping one only costs
226
+ // a little clearance; mixing coordinate spaces would move the leg somewhere wrong.
227
+ if (node.parentId) continue;
228
+ const width = node.measured?.width ?? node.width;
229
+ const height = node.measured?.height ?? node.height;
230
+ if (!width || !height) continue;
231
+ out.push({ x: node.position.x, y: node.position.y, width, height });
232
+ }
233
+ return out;
234
+ }, [isBack, nodes]);
235
+ const detour = isBack
236
+ ? backEdgeDetour(
237
+ rects,
238
+ axis,
239
+ axis === "vertical" ? [sourceY, targetY] : [sourceX, targetX],
240
+ BACK_EDGE_CLEARANCE,
241
+ )
242
+ : null;
243
+
244
+ const [edgePath, labelX, labelY] = isBack
245
+ ? getSmoothStepPath({
246
+ sourceX,
247
+ sourceY,
248
+ sourcePosition,
249
+ targetX,
250
+ targetY,
251
+ targetPosition,
252
+ offset: BACK_EDGE_CLEARANCE,
253
+ ...(detour === null ? {} : axis === "vertical" ? { centerX: detour } : { centerY: detour }),
254
+ })
255
+ : pathType === "smoothstep"
256
+ ? getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition })
257
+ : getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition });
258
+
259
+ const scaledWidth = widthByEdgeId.get(id) ?? DEFAULT_EDGE_WIDTH_RANGE[0];
260
+ const valueColor = useMemo(
261
+ () => resolveValueStrokeColor(data?.value, data?.valueDomain),
262
+ [data?.value, data?.valueDomain],
263
+ );
264
+
265
+ const stroke = selected ? "var(--ring)" : (valueColor ?? "var(--flow-edge)");
266
+ const strokeWidth = selected ? scaledWidth + 1.5 : scaledWidth;
267
+
268
+ const edge = (
269
+ <FlowEdgePath
270
+ id={id}
271
+ path={edgePath}
272
+ markerEnd={markerEnd}
273
+ data-slot="flow-weighted-edge"
274
+ data-variant={variant}
275
+ data-weight={data?.weight}
276
+ data-value={data?.value}
277
+ stroke={stroke}
278
+ strokeWidth={strokeWidth}
279
+ strokeDasharray={isBack ? BACK_EDGE_DASHARRAY : undefined}
280
+ strokeOpacity={isBack ? BACK_EDGE_OPACITY : undefined}
281
+ style={style}
282
+ />
283
+ );
284
+
285
+ return (
286
+ <>
287
+ {isBack ? (
288
+ // A `data-variant` is invisible to assistive technology, so the back
289
+ // edge's meaning is also published as a named graphic. Only the back
290
+ // variant is wrapped — a forward edge's DOM is unchanged from before
291
+ // this prop existed.
292
+ <g role="img" aria-label={data?.variantLabel ?? DEFAULT_BACK_EDGE_LABEL}>
293
+ {edge}
294
+ </g>
295
+ ) : (
296
+ edge
297
+ )}
298
+ <EdgeLabelPill
299
+ label={data?.label}
300
+ secondaryLabel={data?.secondaryLabel}
301
+ x={labelX}
302
+ y={labelY}
303
+ selected={selected}
304
+ {...data?.labelProps}
305
+ />
306
+ </>
307
+ );
308
+ }
@@ -0,0 +1,18 @@
1
+ export {
2
+ FlowWeightedEdge,
3
+ type FlowWeightedEdgeData,
4
+ type BrandFlowWeightedEdge,
5
+ } from "./flow-weighted-edge";
6
+ export { EdgeLabelPill, type EdgeLabelPillProps } from "./edge-label-pill";
7
+ export { backEdgeDetour, type BackEdgeAxis, type BackEdgeNodeRect } from "./back-edge-geometry";
8
+ export {
9
+ computeEdgeWeightScale,
10
+ DEFAULT_EDGE_WIDTH_RANGE,
11
+ type EdgeWeightScaleOptions,
12
+ type WeightedEdgeLike,
13
+ } from "./weight-scale";
14
+ export {
15
+ buildWeightedEdgeAriaLabel,
16
+ withWeightedEdgeAria,
17
+ type WeightedEdgeAriaOptions,
18
+ } from "./edge-aria";
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ computeEdgeWeightScale,
4
+ DEFAULT_EDGE_WIDTH_RANGE,
5
+ type WeightedEdgeLike,
6
+ } from "./weight-scale";
7
+
8
+ describe("computeEdgeWeightScale", () => {
9
+ it("spans exactly the default [1.5, 8] range, linearly, for weights 1..10", () => {
10
+ const edges: WeightedEdgeLike[] = Array.from({ length: 10 }, (_, i) => ({
11
+ id: `e${i + 1}`,
12
+ data: { weight: i + 1 },
13
+ }));
14
+ const scale = computeEdgeWeightScale(edges);
15
+ expect(scale.get("e1")).toBeCloseTo(1.5);
16
+ expect(scale.get("e10")).toBeCloseTo(8);
17
+ // Linear: the midpoint weight (5.5) lands at the midpoint width.
18
+ const mid = (1.5 + 8) / 2;
19
+ const w5 = scale.get("e5")!;
20
+ const w6 = scale.get("e6")!;
21
+ expect((w5 + w6) / 2).toBeCloseTo(mid, 1);
22
+ });
23
+
24
+ it("gives an edge with no data.weight the range floor (matches the existing fixed 1.5px edge)", () => {
25
+ const edges: WeightedEdgeLike[] = [
26
+ { id: "unweighted" },
27
+ { id: "weighted", data: { weight: 4 } },
28
+ ];
29
+ const scale = computeEdgeWeightScale(edges);
30
+ expect(scale.get("unweighted")).toBe(DEFAULT_EDGE_WIDTH_RANGE[0]);
31
+ });
32
+
33
+ it("scales edges sharing a scaleGroup against one domain, independent of other groups", () => {
34
+ const edges: WeightedEdgeLike[] = [
35
+ { id: "a1", data: { weight: 1, scaleGroup: "a" } },
36
+ { id: "a2", data: { weight: 2, scaleGroup: "a" } },
37
+ { id: "b1", data: { weight: 100, scaleGroup: "b" } },
38
+ { id: "b2", data: { weight: 200, scaleGroup: "b" } },
39
+ ];
40
+ const scale = computeEdgeWeightScale(edges);
41
+ expect(scale.get("a1")).toBeCloseTo(1.5);
42
+ expect(scale.get("a2")).toBeCloseTo(8);
43
+ expect(scale.get("b1")).toBeCloseTo(1.5);
44
+ expect(scale.get("b2")).toBeCloseTo(8);
45
+ });
46
+
47
+ it("groups edges with no scaleGroup into one shared default domain", () => {
48
+ const edges: WeightedEdgeLike[] = [
49
+ { id: "x", data: { weight: 0 } },
50
+ { id: "y", data: { weight: 10 } },
51
+ ];
52
+ const scale = computeEdgeWeightScale(edges);
53
+ expect(scale.get("x")).toBeCloseTo(1.5);
54
+ expect(scale.get("y")).toBeCloseTo(8);
55
+ });
56
+
57
+ it("gives the sole member of a group (no variance) the midpoint of the range", () => {
58
+ const edges: WeightedEdgeLike[] = [{ id: "solo", data: { weight: 42, scaleGroup: "only" } }];
59
+ const scale = computeEdgeWeightScale(edges);
60
+ expect(scale.get("solo")).toBeCloseTo((1.5 + 8) / 2);
61
+ });
62
+
63
+ it("honours a custom widthRange", () => {
64
+ const edges: WeightedEdgeLike[] = [
65
+ { id: "a", data: { weight: 0 } },
66
+ { id: "b", data: { weight: 10 } },
67
+ ];
68
+ const scale = computeEdgeWeightScale(edges, { widthRange: [2, 20] });
69
+ expect(scale.get("a")).toBeCloseTo(2);
70
+ expect(scale.get("b")).toBeCloseTo(20);
71
+ });
72
+
73
+ it("restricting to a scaleGroup excludes edges from other groups from the result", () => {
74
+ const edges: WeightedEdgeLike[] = [
75
+ { id: "a1", data: { weight: 1, scaleGroup: "a" } },
76
+ { id: "b1", data: { weight: 100, scaleGroup: "b" } },
77
+ ];
78
+ const scale = computeEdgeWeightScale(edges, { scaleGroup: "a" });
79
+ expect(scale.has("a1")).toBe(true);
80
+ expect(scale.has("b1")).toBe(false);
81
+ });
82
+
83
+ it("is pure — the same input always produces equal output", () => {
84
+ const edges: WeightedEdgeLike[] = [
85
+ { id: "a", data: { weight: 3 } },
86
+ { id: "b", data: { weight: 7 } },
87
+ ];
88
+ const first = computeEdgeWeightScale(edges);
89
+ const second = computeEdgeWeightScale(edges);
90
+ expect([...first.entries()]).toEqual([...second.entries()]);
91
+ });
92
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * weight-scale — pure, framework-free "edge weight → stroke width" scale.
3
+ *
4
+ * `FlowWeightedEdge` calls this with every edge in the flow (via `useEdges()`,
5
+ * memoized) so a group of edges can be min-maxed against ONE shared domain
6
+ * instead of each edge picking its own arbitrary width. No React, no DOM: a
7
+ * sibling (e.g. RM-045's continuous `Legend`) can call it directly to render
8
+ * the same ramp the edges use, and it is trivially unit-testable.
9
+ */
10
+
11
+ /** Minimal edge shape the scale needs — a subset of `Edge<FlowWeightedEdgeData>`. */
12
+ export interface WeightedEdgeLike {
13
+ id: string;
14
+ data?: {
15
+ weight?: number;
16
+ scaleGroup?: string;
17
+ };
18
+ }
19
+
20
+ export interface EdgeWeightScaleOptions {
21
+ /** Output stroke-width range, in px. @default [1.5, 8] */
22
+ widthRange?: [number, number];
23
+ /**
24
+ * Restrict the domain calculation (and the returned map) to edges whose
25
+ * `data.scaleGroup` equals this value — edges in a different group are
26
+ * skipped entirely. Omit to compute one scale per distinct `scaleGroup`
27
+ * present in `edges` (edges with no `scaleGroup` share one implicit
28
+ * default group, so a flow that never sets it still gets one shared
29
+ * domain — "all edges in the same `<ReactFlow>`").
30
+ */
31
+ scaleGroup?: string;
32
+ }
33
+
34
+ /** Matches today's fixed 1.5px `FlowEdge` stroke, so an unweighted edge is unchanged. */
35
+ export const DEFAULT_EDGE_WIDTH_RANGE: [number, number] = [1.5, 8];
36
+
37
+ const DEFAULT_SCALE_GROUP = "__default__";
38
+
39
+ /**
40
+ * Resolve every edge's `data.weight` into a stroke-width, linearly min-maxed
41
+ * into `widthRange` per `scaleGroup`. An edge with no `data.weight` gets the
42
+ * range floor — the existing fixed 1.5px `FlowEdge` already draws, so a plain
43
+ * edge renders unchanged. An edge that is the only member of its group (or
44
+ * whose group has zero weight variance) gets the midpoint of the range —
45
+ * there is no domain to compare it against.
46
+ */
47
+ export function computeEdgeWeightScale(
48
+ edges: WeightedEdgeLike[],
49
+ opts: EdgeWeightScaleOptions = {},
50
+ ): Map<string, number> {
51
+ const [minWidth, maxWidth] = opts.widthRange ?? DEFAULT_EDGE_WIDTH_RANGE;
52
+ const result = new Map<string, number>();
53
+ const groups = new Map<string, { id: string; weight: number }[]>();
54
+
55
+ for (const edge of edges) {
56
+ const weight = edge.data?.weight;
57
+ if (weight === undefined) {
58
+ result.set(edge.id, minWidth);
59
+ continue;
60
+ }
61
+ const group = edge.data?.scaleGroup ?? DEFAULT_SCALE_GROUP;
62
+ if (opts.scaleGroup !== undefined && group !== opts.scaleGroup) continue;
63
+ const list = groups.get(group);
64
+ if (list) list.push({ id: edge.id, weight });
65
+ else groups.set(group, [{ id: edge.id, weight }]);
66
+ }
67
+
68
+ for (const list of groups.values()) {
69
+ let min = Infinity;
70
+ let max = -Infinity;
71
+ for (const { weight } of list) {
72
+ if (weight < min) min = weight;
73
+ if (weight > max) max = weight;
74
+ }
75
+ const span = max - min;
76
+ for (const { id, weight } of list) {
77
+ const width =
78
+ span === 0
79
+ ? (minWidth + maxWidth) / 2
80
+ : minWidth + ((weight - min) / span) * (maxWidth - minWidth);
81
+ result.set(id, width);
82
+ }
83
+ }
84
+
85
+ return result;
86
+ }
package/src/index.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  * import "@xyflow/react/dist/style.css";
6
6
  */
7
7
  export * from "./canvas-shell";
8
+ export * from "./flow-handle";
8
9
  export * from "./flow-node";
9
10
  export * from "./flow-edge";
11
+ // The shared edge path + keyboard focus indicator every edge type draws through (#286).
12
+ export * from "./flow-edge-path";
10
13
  export * from "./flow-smart-edge";
11
14
  export * from "./flow-floating-edge";
12
15
  export * from "./flow-placeholder-node";
@@ -35,3 +38,9 @@ export {
35
38
  addEdge,
36
39
  } from "@xyflow/react";
37
40
  export type { Node, Edge, Connection, NodeProps, EdgeProps } from "@xyflow/react";
41
+
42
+ // FlowWeightedEdge — RM-043
43
+ export * from "./flow-weighted-edge";
44
+
45
+ // FlowSelfLoopEdge — RM-044
46
+ export * from "./flow-self-loop-edge";
@@ -93,7 +93,7 @@ function CollapsibleDemo() {
93
93
  type="button"
94
94
  aria-expanded={open}
95
95
  onClick={() => setOpen((o) => !o)}
96
- className="rounded-md border border-input px-3 py-1.5 text-body hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
96
+ className="rounded-md border border-input px-3 py-1.5 text-body hover:bg-accent focus-ring"
97
97
  >
98
98
  Toggle inspector
99
99
  </button>
@@ -79,4 +79,24 @@ describe("InspectorPanel", () => {
79
79
  // Content stays mounted (the width tween needs a mounted node).
80
80
  expect(screen.getByText("Details")).toBeInTheDocument();
81
81
  });
82
+
83
+ it("keeps its scrollable body reachable by keyboard, selected or not", () => {
84
+ // A region that scrolls but holds nothing focusable is readable with a mouse and
85
+ // unreachable without one (axe `scrollable-region-focusable`). Inspector content is
86
+ // usually exactly that — text, a definition list, a chart — so the container itself
87
+ // has to take focus. Asserted in BOTH branches: the empty message scrolls too.
88
+ const { container, rerender } = render(
89
+ <InspectorPanel hasSelection={false} emptyMessage="Nothing selected.">
90
+ <p>Hidden while nothing is selected.</p>
91
+ </InspectorPanel>,
92
+ );
93
+ expect(container.querySelector(".overflow-y-auto")).toHaveAttribute("tabindex", "0");
94
+
95
+ rerender(
96
+ <InspectorPanel hasSelection>
97
+ <p>Plain text, nothing focusable.</p>
98
+ </InspectorPanel>,
99
+ );
100
+ expect(container.querySelector(".overflow-y-auto")).toHaveAttribute("tabindex", "0");
101
+ });
82
102
  });
@@ -90,7 +90,7 @@ export function InspectorPanel({
90
90
  type="button"
91
91
  aria-label="Close inspector"
92
92
  onClick={onClose}
93
- className="rounded-sm p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
93
+ className="rounded-sm p-1 text-muted-foreground hover:text-foreground focus-ring"
94
94
  >
95
95
  <svg
96
96
  width="14"
@@ -108,17 +108,27 @@ export function InspectorPanel({
108
108
  </button>
109
109
  ) : null}
110
110
  </div>
111
+ {/*
112
+ `tabIndex={0}` on a scroll container, in both branches: a region that scrolls
113
+ but contains nothing focusable is unreachable by keyboard — a mouse user can
114
+ read the overflow and a keyboard user cannot (axe
115
+ `scrollable-region-focusable`). An inspector body is exactly that shape
116
+ whenever its content is plain text, a definition list or a chart, which is
117
+ most of the time. It carries `focus-ring` because anything that can take
118
+ focus has to show it.
119
+ */}
111
120
  {hasSelection ? (
112
121
  <Reveal
113
122
  key={selectionKey}
114
123
  appear="fade"
115
124
  speed="fast"
116
- className="flex-1 overflow-y-auto p-4 text-body"
125
+ tabIndex={0}
126
+ className="focus-ring flex-1 overflow-y-auto p-4 text-body"
117
127
  >
118
128
  {children}
119
129
  </Reveal>
120
130
  ) : (
121
- <div className="flex-1 overflow-y-auto p-4 text-body">
131
+ <div tabIndex={0} className="focus-ring flex-1 overflow-y-auto p-4 text-body">
122
132
  <p className="text-muted-foreground">{emptyMessage}</p>
123
133
  </div>
124
134
  )}
@@ -1 +1,7 @@
1
- export { Legend, type LegendProps, type LegendItem } from "./legend";
1
+ export {
2
+ Legend,
3
+ type LegendProps,
4
+ type LegendItem,
5
+ type LegendCategoricalProps,
6
+ type LegendScaleProps,
7
+ } from "./legend";