@elabs-ai/components-flow 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/index.d.ts +738 -20
- package/dist/index.js +985 -182
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
- package/src/__contract__/legend.contract.test.tsx +49 -0
- package/src/canvas-shell/canvas-shell.tsx +116 -1
- package/src/canvas-shell/use-measured-nodes.ts +101 -0
- package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
- package/src/flow-button-edge/flow-button-edge.tsx +8 -10
- package/src/flow-edge/flow-edge.stories.tsx +20 -0
- package/src/flow-edge/flow-edge.tsx +10 -3
- package/src/flow-edge-path/flow-edge-path.tsx +149 -0
- package/src/flow-edge-path/index.ts +1 -0
- package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
- package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
- package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
- package/src/flow-group-node/flow-group-node.tsx +24 -8
- package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
- package/src/flow-handle/flow-handle-anchor.ts +36 -0
- package/src/flow-handle/index.ts +1 -0
- package/src/flow-layout/flow-layout.stories.tsx +2 -2
- package/src/flow-layout/flow-layout.test.tsx +91 -0
- package/src/flow-layout/flow-layout.ts +77 -1
- package/src/flow-layout/layout-graph.test.ts +83 -2
- package/src/flow-layout/layout-graph.ts +23 -15
- package/src/flow-mini-map/flow-mini-map.stories.tsx +103 -0
- package/src/flow-node/flow-node.stories.tsx +151 -0
- package/src/flow-node/flow-node.tsx +56 -1
- package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
- package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
- package/src/flow-self-loop-edge/index.ts +13 -0
- package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
- package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
- package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
- package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
- package/src/flow-smart-edge/index.ts +5 -1
- package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
- package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
- package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
- package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
- package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
- package/src/flow-weighted-edge/edge-aria.ts +117 -0
- package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
- package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
- package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
- package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
- package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
- package/src/flow-weighted-edge/index.ts +18 -0
- package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
- package/src/flow-weighted-edge/weight-scale.ts +86 -0
- package/src/index.ts +9 -0
- package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
- package/src/inspector-panel/inspector-panel.test.tsx +20 -0
- package/src/inspector-panel/inspector-panel.tsx +22 -9
- package/src/legend/index.ts +7 -1
- package/src/legend/legend.stories.tsx +126 -0
- package/src/legend/legend.test.tsx +180 -0
- package/src/legend/legend.tsx +222 -3
- package/src/templates-flow-workspace.stories.tsx +1 -1
- package/src/testing/canvas-framing.test.ts +107 -0
- package/src/testing/canvas-framing.ts +396 -0
- package/src/testing/edge-anchors.ts +107 -0
- package/src/testing/index.ts +36 -0
- 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-
|
|
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
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
-
import { Reveal, useCollapsiblePanel } from "@elabs-ai/components-ui";
|
|
2
|
+
import { Reveal, useCollapsiblePanel, useLocale } from "@elabs-ai/components-ui";
|
|
3
3
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
4
4
|
|
|
5
5
|
const INSPECTOR_PANEL_WIDTH = "18rem";
|
|
@@ -41,10 +41,10 @@ export interface InspectorPanelProps {
|
|
|
41
41
|
* motion safe) when `open`/`defaultOpen` drive it.
|
|
42
42
|
*/
|
|
43
43
|
export function InspectorPanel({
|
|
44
|
-
title
|
|
44
|
+
title,
|
|
45
45
|
children,
|
|
46
46
|
onClose,
|
|
47
|
-
emptyMessage
|
|
47
|
+
emptyMessage,
|
|
48
48
|
hasSelection = true,
|
|
49
49
|
selectionKey,
|
|
50
50
|
open,
|
|
@@ -54,6 +54,9 @@ export function InspectorPanel({
|
|
|
54
54
|
width = INSPECTOR_PANEL_WIDTH,
|
|
55
55
|
className,
|
|
56
56
|
}: InspectorPanelProps) {
|
|
57
|
+
const { t } = useLocale();
|
|
58
|
+
const resolvedTitle = title ?? t("flow.inspectorPanel.title");
|
|
59
|
+
const resolvedEmptyMessage = emptyMessage ?? t("flow.inspectorPanel.emptyMessage");
|
|
57
60
|
const panel = useCollapsiblePanel({
|
|
58
61
|
side,
|
|
59
62
|
open,
|
|
@@ -84,13 +87,13 @@ export function InspectorPanel({
|
|
|
84
87
|
{/* Detail-tier surface: raised `card` — robust across themes (#193). */}
|
|
85
88
|
<div className="flex h-full w-full flex-col bg-card">
|
|
86
89
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
|
87
|
-
<h3 className="text-body font-semibold text-foreground">{
|
|
90
|
+
<h3 className="text-body font-semibold text-foreground">{resolvedTitle}</h3>
|
|
88
91
|
{onClose ? (
|
|
89
92
|
<button
|
|
90
93
|
type="button"
|
|
91
|
-
aria-label="
|
|
94
|
+
aria-label={t("flow.inspectorPanel.close")}
|
|
92
95
|
onClick={onClose}
|
|
93
|
-
className="rounded-sm p-1 text-muted-foreground hover:text-foreground focus-
|
|
96
|
+
className="rounded-sm p-1 text-muted-foreground hover:text-foreground focus-ring"
|
|
94
97
|
>
|
|
95
98
|
<svg
|
|
96
99
|
width="14"
|
|
@@ -108,18 +111,28 @@ export function InspectorPanel({
|
|
|
108
111
|
</button>
|
|
109
112
|
) : null}
|
|
110
113
|
</div>
|
|
114
|
+
{/*
|
|
115
|
+
`tabIndex={0}` on a scroll container, in both branches: a region that scrolls
|
|
116
|
+
but contains nothing focusable is unreachable by keyboard — a mouse user can
|
|
117
|
+
read the overflow and a keyboard user cannot (axe
|
|
118
|
+
`scrollable-region-focusable`). An inspector body is exactly that shape
|
|
119
|
+
whenever its content is plain text, a definition list or a chart, which is
|
|
120
|
+
most of the time. It carries `focus-ring` because anything that can take
|
|
121
|
+
focus has to show it.
|
|
122
|
+
*/}
|
|
111
123
|
{hasSelection ? (
|
|
112
124
|
<Reveal
|
|
113
125
|
key={selectionKey}
|
|
114
126
|
appear="fade"
|
|
115
127
|
speed="fast"
|
|
116
|
-
|
|
128
|
+
tabIndex={0}
|
|
129
|
+
className="focus-ring flex-1 overflow-y-auto p-4 text-body"
|
|
117
130
|
>
|
|
118
131
|
{children}
|
|
119
132
|
</Reveal>
|
|
120
133
|
) : (
|
|
121
|
-
<div className="flex-1 overflow-y-auto p-4 text-body">
|
|
122
|
-
<p className="text-muted-foreground">{
|
|
134
|
+
<div tabIndex={0} className="focus-ring flex-1 overflow-y-auto p-4 text-body">
|
|
135
|
+
<p className="text-muted-foreground">{resolvedEmptyMessage}</p>
|
|
123
136
|
</div>
|
|
124
137
|
)}
|
|
125
138
|
</div>
|