@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
|
@@ -2,6 +2,20 @@ import dagre from "@dagrejs/dagre";
|
|
|
2
2
|
import { Position } from "@xyflow/react";
|
|
3
3
|
import type { Edge, Node } from "@xyflow/react";
|
|
4
4
|
|
|
5
|
+
/** The graphlib graph `dagre.graphlib.Graph` produces — dagre exports no standalone type for it. */
|
|
6
|
+
type DagreGraph = InstanceType<typeof dagre.graphlib.Graph>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The fields dagre writes back onto a node label during `layout()`. `rank` is
|
|
10
|
+
* absent from dagre's own `.d.ts` (it is documented output, not declared
|
|
11
|
+
* output), so it is narrowed here rather than asserted at the call site.
|
|
12
|
+
*/
|
|
13
|
+
interface DagreNodeLabel {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
rank?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
/** Direction dagre lays the graph out in — top-to-bottom, left-to-right, etc. */
|
|
6
20
|
export type FlowLayoutDirection = "TB" | "LR" | "BT" | "RL";
|
|
7
21
|
|
|
@@ -35,6 +49,22 @@ export interface FlowLayoutOptions {
|
|
|
35
49
|
export interface FlowLayoutResult<NodeType extends Node = Node, EdgeType extends Edge = Edge> {
|
|
36
50
|
nodes: NodeType[];
|
|
37
51
|
edges: EdgeType[];
|
|
52
|
+
/**
|
|
53
|
+
* Ids of edges that run **against** the layout direction — a rework / retry
|
|
54
|
+
* loop in a process graph. dagre breaks cycles by reversing such edges
|
|
55
|
+
* internally and never surfaces which ones it reversed, so this is derived
|
|
56
|
+
* from the ranks dagre stamps on the laid-out graph: an edge whose source
|
|
57
|
+
* ranks at or after its target went backwards. Render these with
|
|
58
|
+
* `FlowWeightedEdge`'s `variant="back"`.
|
|
59
|
+
*/
|
|
60
|
+
backEdges: string[];
|
|
61
|
+
/**
|
|
62
|
+
* Ids of edges whose `source === target`. dagre does not lay out self-loops,
|
|
63
|
+
* so they are withheld from the graph entirely (never `setEdge`-ed) and are
|
|
64
|
+
* returned unchanged in `edges` — they take part in no rank computation and
|
|
65
|
+
* cannot distort the layout. Render these with `FlowSelfLoopEdge`.
|
|
66
|
+
*/
|
|
67
|
+
selfLoops: string[];
|
|
38
68
|
}
|
|
39
69
|
|
|
40
70
|
/** Fallback size used when a node hasn't been measured yet (React Flow's own default node width). */
|
|
@@ -58,6 +88,11 @@ function nodeSize(node: Node): { width: number; height: number } {
|
|
|
58
88
|
* `node.height`, then a sensible default. Node identity and `data` are left
|
|
59
89
|
* untouched — only `position` changes.
|
|
60
90
|
*
|
|
91
|
+
* Also reports the graph's two structural signals — `backEdges` (edges that run
|
|
92
|
+
* against the layout direction) and `selfLoops` (`source === target`). Both are
|
|
93
|
+
* additive fields on the result; a caller that only destructures
|
|
94
|
+
* `{ nodes, edges }` is unaffected.
|
|
95
|
+
*
|
|
61
96
|
* Pair with `useFlowLayout` to apply the result to a live canvas.
|
|
62
97
|
*/
|
|
63
98
|
export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
|
|
@@ -76,12 +111,22 @@ export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge =
|
|
|
76
111
|
graph.setNode(node.id, { width, height });
|
|
77
112
|
}
|
|
78
113
|
|
|
114
|
+
// Self-loops are withheld from dagre entirely: dagre does not lay them out,
|
|
115
|
+
// and feeding them in only perturbs the ranks of a graph they say nothing
|
|
116
|
+
// about. They are re-attached untouched in the returned `edges`.
|
|
117
|
+
const selfLoops: string[] = [];
|
|
79
118
|
for (const edge of edges) {
|
|
119
|
+
if (edge.source === edge.target) {
|
|
120
|
+
selfLoops.push(edge.id);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
80
123
|
graph.setEdge(edge.source, edge.target);
|
|
81
124
|
}
|
|
82
125
|
|
|
83
126
|
dagre.layout(graph);
|
|
84
127
|
|
|
128
|
+
const backEdges = collectBackEdges(graph, edges);
|
|
129
|
+
|
|
85
130
|
const handles = HANDLE_BY_DIRECTION[direction];
|
|
86
131
|
const layoutedNodes = nodes.map((node) => {
|
|
87
132
|
const dagreNode = graph.node(node.id);
|
|
@@ -100,5 +145,36 @@ export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge =
|
|
|
100
145
|
};
|
|
101
146
|
});
|
|
102
147
|
|
|
103
|
-
return { nodes: layoutedNodes, edges };
|
|
148
|
+
return { nodes: layoutedNodes, edges, backEdges, selfLoops };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Which edges dagre had to run backwards, derived from the ranks it stamps on
|
|
153
|
+
* the laid-out graph (`graph.node(id).rank`).
|
|
154
|
+
*
|
|
155
|
+
* The obvious alternative — reading `graph.edge(e).reversed` after dagre's
|
|
156
|
+
* `acyclic.run` — is **not usable on the pinned `@dagrejs/dagre` 3.0.0**:
|
|
157
|
+
* cycle breaking happens on an internal copy of the graph, and the caller's
|
|
158
|
+
* graph carries no `reversed` flag once `dagre.layout()` returns (verified
|
|
159
|
+
* against the installed version — every edge label is bare `{ points }`).
|
|
160
|
+
* `rank` *is* on the public graph, and is direction-independent: it counts up
|
|
161
|
+
* along the flow for every `rankdir`, so `rank(source) >= rank(target)` means
|
|
162
|
+
* "this edge does not advance the process" in TB, BT, LR and RL alike.
|
|
163
|
+
*
|
|
164
|
+
* `>=` rather than `>` on purpose: a same-rank edge between two siblings is
|
|
165
|
+
* not forward progress either, and dagre would have had to reverse or flatten
|
|
166
|
+
* it. Self-loops never reach here — they are filtered out before layout.
|
|
167
|
+
* An edge naming a node that isn't in the graph has no ranks to compare and is
|
|
168
|
+
* left out rather than guessed at.
|
|
169
|
+
*/
|
|
170
|
+
function collectBackEdges(graph: DagreGraph, edges: Edge[]): string[] {
|
|
171
|
+
const backEdges: string[] = [];
|
|
172
|
+
for (const edge of edges) {
|
|
173
|
+
if (edge.source === edge.target) continue;
|
|
174
|
+
const sourceRank = (graph.node(edge.source) as DagreNodeLabel | undefined)?.rank;
|
|
175
|
+
const targetRank = (graph.node(edge.target) as DagreNodeLabel | undefined)?.rank;
|
|
176
|
+
if (typeof sourceRank !== "number" || typeof targetRank !== "number") continue;
|
|
177
|
+
if (sourceRank >= targetRank) backEdges.push(edge.id);
|
|
178
|
+
}
|
|
179
|
+
return backEdges;
|
|
104
180
|
}
|
|
@@ -36,7 +36,13 @@ function distance(p: { x: number; y: number }, q: { x: number; y: number } = { x
|
|
|
36
36
|
describe("layoutGraph", () => {
|
|
37
37
|
describe("empty / disconnected input", () => {
|
|
38
38
|
it("returns [] for an empty graph, for every algorithm", () => {
|
|
39
|
-
for (const algorithm of [
|
|
39
|
+
for (const algorithm of [
|
|
40
|
+
"concentric",
|
|
41
|
+
"force",
|
|
42
|
+
"layered-lr",
|
|
43
|
+
"layered-tb",
|
|
44
|
+
"grid",
|
|
45
|
+
] as const) {
|
|
40
46
|
expect(layoutGraph([], [], { algorithm })).toEqual([]);
|
|
41
47
|
}
|
|
42
48
|
});
|
|
@@ -45,7 +51,13 @@ describe("layoutGraph", () => {
|
|
|
45
51
|
const nodes = ["a", "b", "c", "isolated1", "isolated2"].map(node);
|
|
46
52
|
const edges = [edge("a", "b"), edge("b", "c")];
|
|
47
53
|
|
|
48
|
-
for (const algorithm of [
|
|
54
|
+
for (const algorithm of [
|
|
55
|
+
"concentric",
|
|
56
|
+
"force",
|
|
57
|
+
"layered-lr",
|
|
58
|
+
"layered-tb",
|
|
59
|
+
"grid",
|
|
60
|
+
] as const) {
|
|
49
61
|
const laidOut = layoutGraph(nodes, edges, { algorithm, iterations: 20 });
|
|
50
62
|
for (const n of laidOut) {
|
|
51
63
|
expect(Number.isFinite(n.position.x)).toBe(true);
|
|
@@ -254,4 +266,73 @@ describe("layoutGraph", () => {
|
|
|
254
266
|
expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position));
|
|
255
267
|
});
|
|
256
268
|
});
|
|
269
|
+
describe("layered-tb", () => {
|
|
270
|
+
it("delegates to layoutFlow direction=TB, ordering a chain top-to-bottom", () => {
|
|
271
|
+
const { nodes, edges } = chainGraph();
|
|
272
|
+
const laidOut = layoutGraph(nodes, edges, { algorithm: "layered-tb" });
|
|
273
|
+
const [a, b, c] = laidOut as [Node, Node, Node];
|
|
274
|
+
expect(a.position.y).toBeLessThan(b.position.y);
|
|
275
|
+
expect(b.position.y).toBeLessThan(c.position.y);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("stamps bottom-out / top-in handle sides (not just position)", () => {
|
|
279
|
+
const { nodes, edges } = chainGraph();
|
|
280
|
+
const [a] = layoutGraph(nodes, edges, { algorithm: "layered-tb" }) as [Node];
|
|
281
|
+
expect(a.sourcePosition).toBe("bottom");
|
|
282
|
+
expect(a.targetPosition).toBe("top");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("maps spacing.y onto rank spacing (the axis TB ranks along)", () => {
|
|
286
|
+
const { nodes, edges } = chainGraph();
|
|
287
|
+
const tight = layoutGraph(nodes, edges, {
|
|
288
|
+
algorithm: "layered-tb",
|
|
289
|
+
spacing: { x: 10, y: 10 },
|
|
290
|
+
});
|
|
291
|
+
const wide = layoutGraph(nodes, edges, {
|
|
292
|
+
algorithm: "layered-tb",
|
|
293
|
+
spacing: { x: 10, y: 500 },
|
|
294
|
+
});
|
|
295
|
+
const tightGap = tight[1]!.position.y - tight[0]!.position.y;
|
|
296
|
+
const wideGap = wide[1]!.position.y - wide[0]!.position.y;
|
|
297
|
+
expect(wideGap).toBeGreaterThan(tightGap);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("respects a custom nodeSize without permanently resizing the nodes", () => {
|
|
301
|
+
const nodes: Node[] = [
|
|
302
|
+
{ id: "a", type: "brand", position: { x: 0, y: 0 }, data: {} },
|
|
303
|
+
{ id: "b", type: "brand", position: { x: 0, y: 0 }, data: {} },
|
|
304
|
+
];
|
|
305
|
+
const laidOut = layoutGraph(nodes, [edge("a", "b")], {
|
|
306
|
+
algorithm: "layered-tb",
|
|
307
|
+
nodeSize: () => ({ width: 200, height: 400 }),
|
|
308
|
+
});
|
|
309
|
+
expect(laidOut[1]!.position.y - laidOut[0]!.position.y).toBeGreaterThanOrEqual(400 + 72 - 1);
|
|
310
|
+
expect(laidOut[0]).not.toHaveProperty("measured");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("is the same layout as layered-lr with the axes swapped", () => {
|
|
314
|
+
// A fork exercises BOTH axes: a rank progression and a within-rank stack.
|
|
315
|
+
// Square nodes + isotropic spacing make the two layouts exact mirrors, so
|
|
316
|
+
// the assertion is "x and y trade places", not "the numbers look similar".
|
|
317
|
+
const nodes = ["a", "b", "c"].map(node);
|
|
318
|
+
const edges = [edge("a", "b"), edge("a", "c")];
|
|
319
|
+
const options = {
|
|
320
|
+
spacing: { x: 150, y: 150 },
|
|
321
|
+
nodeSize: () => ({ width: 100, height: 100 }),
|
|
322
|
+
};
|
|
323
|
+
const lr = layoutGraph(nodes, edges, { ...options, algorithm: "layered-lr" });
|
|
324
|
+
const tb = layoutGraph(nodes, edges, { ...options, algorithm: "layered-tb" });
|
|
325
|
+
|
|
326
|
+
expect(tb.map((n) => n.position)).toEqual(
|
|
327
|
+
lr.map((n) => ({ x: n.position.y, y: n.position.x })),
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("is deterministic", () => {
|
|
332
|
+
const { nodes, edges } = chainGraph();
|
|
333
|
+
const first = layoutGraph(nodes, edges, { algorithm: "layered-tb" });
|
|
334
|
+
const second = layoutGraph(nodes, edges, { algorithm: "layered-tb" });
|
|
335
|
+
expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position));
|
|
336
|
+
});
|
|
337
|
+
});
|
|
257
338
|
});
|
|
@@ -8,12 +8,12 @@ import {
|
|
|
8
8
|
type SimulationNodeDatum,
|
|
9
9
|
} from "d3-force";
|
|
10
10
|
import type { Edge, Node } from "@xyflow/react";
|
|
11
|
-
import { HANDLE_BY_DIRECTION, layoutFlow } from "./flow-layout";
|
|
11
|
+
import { HANDLE_BY_DIRECTION, layoutFlow, type FlowLayoutDirection } from "./flow-layout";
|
|
12
12
|
|
|
13
13
|
/** Which generic graph-geometry algorithm `layoutGraph` should run. */
|
|
14
|
-
export type LayoutAlgorithm = "concentric" | "force" | "layered-lr" | "grid";
|
|
14
|
+
export type LayoutAlgorithm = "concentric" | "force" | "layered-lr" | "layered-tb" | "grid";
|
|
15
15
|
|
|
16
|
-
/** Horizontal/vertical gap used by the `"grid"` and `"layered
|
|
16
|
+
/** Horizontal/vertical gap used by the `"grid"` and `"layered-*"` algorithms. */
|
|
17
17
|
export interface LayoutSpacing {
|
|
18
18
|
x: number;
|
|
19
19
|
y: number;
|
|
@@ -31,7 +31,7 @@ export interface LayoutOptions {
|
|
|
31
31
|
ringRadius?: number;
|
|
32
32
|
/** `"force"` only — number of synchronous simulation ticks to run. @default 300 */
|
|
33
33
|
iterations?: number;
|
|
34
|
-
/** `"grid"` / `"layered-lr"` — gap between nodes. @default {x:200,y:120} for grid. */
|
|
34
|
+
/** `"grid"` / `"layered-lr"` / `"layered-tb"` — gap between nodes. @default {x:200,y:120} for grid. */
|
|
35
35
|
spacing?: LayoutSpacing;
|
|
36
36
|
/** Resolve a node's size by id. Falls back to `measured`/`width`/`height`/a sensible default. */
|
|
37
37
|
nodeSize?: (id: string) => { width: number; height: number };
|
|
@@ -66,7 +66,10 @@ function resolveNodeSize(
|
|
|
66
66
|
* Node identity and `data` are left untouched — only `position` changes.
|
|
67
67
|
* Pair with `useAutoLayout` for a memoized hook form.
|
|
68
68
|
*
|
|
69
|
-
* - `"layered-lr"`
|
|
69
|
+
* - `"layered-lr"` / `"layered-tb"` delegate to the dagre-powered `layoutFlow`
|
|
70
|
+
* (direction `"LR"` / `"TB"`). `spacing.x` is always the horizontal gap and
|
|
71
|
+
* `spacing.y` the vertical one, so the two differ only in which axis carries
|
|
72
|
+
* the ranks.
|
|
70
73
|
* - `"concentric"` places `centerId` (or the highest-degree node) at the
|
|
71
74
|
* origin, with BFS shells at `ring × ringRadius`; disconnected nodes land in
|
|
72
75
|
* one extra outer ring so positions are never `NaN`.
|
|
@@ -83,7 +86,9 @@ export function layoutGraph<NodeType extends Node = Node, EdgeType extends Edge
|
|
|
83
86
|
|
|
84
87
|
switch (options.algorithm) {
|
|
85
88
|
case "layered-lr":
|
|
86
|
-
return
|
|
89
|
+
return layoutLayered(nodes, edges, options, "LR");
|
|
90
|
+
case "layered-tb":
|
|
91
|
+
return layoutLayered(nodes, edges, options, "TB");
|
|
87
92
|
case "concentric":
|
|
88
93
|
return layoutConcentric(nodes, edges, options);
|
|
89
94
|
case "force":
|
|
@@ -97,10 +102,11 @@ export function layoutGraph<NodeType extends Node = Node, EdgeType extends Edge
|
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
function
|
|
105
|
+
function layoutLayered<NodeType extends Node, EdgeType extends Edge>(
|
|
101
106
|
nodes: NodeType[],
|
|
102
107
|
edges: EdgeType[],
|
|
103
108
|
options: LayoutOptions,
|
|
109
|
+
direction: FlowLayoutDirection,
|
|
104
110
|
): NodeType[] {
|
|
105
111
|
const { spacing, nodeSize } = options;
|
|
106
112
|
|
|
@@ -114,17 +120,19 @@ function layoutLayeredLr<NodeType extends Node, EdgeType extends Edge>(
|
|
|
114
120
|
})
|
|
115
121
|
: nodes;
|
|
116
122
|
|
|
123
|
+
// Ranks progress along the layout axis and nodes stack across it, so the two
|
|
124
|
+
// directions map the SAME `spacing.x`/`spacing.y` onto dagre's rank/node sep
|
|
125
|
+
// with the axes swapped: LR ranks horizontally (x), TB ranks vertically (y).
|
|
126
|
+
const horizontalRanks = direction === "LR" || direction === "RL";
|
|
117
127
|
const { nodes: laidOut } = layoutFlow(sizedNodes, edges, {
|
|
118
|
-
direction
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
nodeSpacing: spacing?.y,
|
|
122
|
-
rankSpacing: spacing?.x,
|
|
128
|
+
direction,
|
|
129
|
+
nodeSpacing: horizontalRanks ? spacing?.y : spacing?.x,
|
|
130
|
+
rankSpacing: horizontalRanks ? spacing?.x : spacing?.y,
|
|
123
131
|
});
|
|
124
132
|
|
|
125
|
-
// Carry the
|
|
126
|
-
// not just the position — otherwise the layout
|
|
127
|
-
// anchors on
|
|
133
|
+
// Carry the handle sides layoutFlow stamped on (LR right-out/left-in, TB
|
|
134
|
+
// bottom-out/top-in), not just the position — otherwise the layout moves the
|
|
135
|
+
// nodes but leaves the anchors on the wrong sides.
|
|
128
136
|
return nodes.map((node, i) => ({
|
|
129
137
|
...node,
|
|
130
138
|
sourcePosition: laidOut[i]!.sourcePosition,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import "@xyflow/react/dist/style.css";
|
|
3
|
+
import { expect, waitFor } from "storybook/test";
|
|
3
4
|
import { CanvasShell } from "../canvas-shell";
|
|
4
5
|
import { FlowNode, FLOW_ALL_SIDE_HANDLES, type BrandFlowNode } from "../flow-node";
|
|
5
6
|
import { FlowSmartEdge } from "../flow-smart-edge";
|
|
@@ -71,4 +72,106 @@ export const Default: Story = {
|
|
|
71
72
|
</CanvasShell>
|
|
72
73
|
</div>
|
|
73
74
|
),
|
|
75
|
+
/**
|
|
76
|
+
* The minimap must actually DRAW the nodes — this story rendered a blank white panel
|
|
77
|
+
* for as long as it has existed, and nothing caught it: React Flow's `<MiniMap>` reads
|
|
78
|
+
* each node's dimensions off the object the CONSUMER passed (`internals.userNode`), and
|
|
79
|
+
* bails out per node when they are absent. On a controlled canvas with no
|
|
80
|
+
* `onNodesChange` applying React Flow's own `dimensions` changes back, they always were.
|
|
81
|
+
* `CanvasShell` now merges the measurements in (`useMeasuredNodes`), so the count below
|
|
82
|
+
* is the honest proof — asserting the `<svg>` merely exists passes on the broken state.
|
|
83
|
+
*
|
|
84
|
+
* The count alone is not the whole issue (#363): its second hypothesis was a
|
|
85
|
+
* `nodeColor`/`nodeStrokeColor` token resolving to nothing, which paints the same
|
|
86
|
+
* rects INVISIBLY rather than omitting them — a count-only assertion would pass on
|
|
87
|
+
* that failure too. So this also resolves the rect's actual painted fill against the
|
|
88
|
+
* panel's actual painted background and demands real contrast, at the FILL-rung floor
|
|
89
|
+
* this repo already holds status marks to (`styling-and-tokens.md`). Same
|
|
90
|
+
* canvas-readback pattern `FlowNode`'s `FocusIndicator` / `FlowWeightedEdge`'s
|
|
91
|
+
* `KeyboardFocus` locks use to turn a CSS colour string (`oklch()` included) into a
|
|
92
|
+
* real measurement instead of an assumption.
|
|
93
|
+
*/
|
|
94
|
+
play: async ({ canvasElement }) => {
|
|
95
|
+
let minimap!: HTMLElement;
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
const el = canvasElement.querySelector<HTMLElement>(".react-flow__minimap");
|
|
98
|
+
expect(el).toBeTruthy();
|
|
99
|
+
expect(el!.querySelectorAll(".react-flow__minimap-node")).toHaveLength(nodes.length);
|
|
100
|
+
// The viewport mask is derived from the transform, not from node geometry — it
|
|
101
|
+
// never broke, but the fix for this issue touches nothing about it either, so
|
|
102
|
+
// this pins it as unchanged rather than leaving it unasserted.
|
|
103
|
+
expect(el!.querySelector(".react-flow__minimap-mask")).toBeTruthy();
|
|
104
|
+
minimap = el!;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// `getImageData()` reports straight (non-premultiplied) RGBA — the alpha channel is
|
|
108
|
+
// real, but reading r/g/b alone discards it: a fully transparent fill (alpha 0) still
|
|
109
|
+
// returns SOME rgb triple (typically 0,0,0), which can measure as opaque black and pass
|
|
110
|
+
// contrast against a light panel while nothing is actually painted. Compositing the
|
|
111
|
+
// fill ON TOP OF the real panel background first — the same source-over the browser
|
|
112
|
+
// performs when it paints the rect — means a zero-alpha fill reads back AS the panel
|
|
113
|
+
// background, so it can never clear the threshold below (#409 review).
|
|
114
|
+
const toSrgbOverBackground = (colour: string, background: string): [number, number, number] => {
|
|
115
|
+
const surface = document.createElement("canvas");
|
|
116
|
+
surface.width = 1;
|
|
117
|
+
surface.height = 1;
|
|
118
|
+
const ctx = surface.getContext("2d")!;
|
|
119
|
+
ctx.fillStyle = background;
|
|
120
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
121
|
+
ctx.fillStyle = colour;
|
|
122
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
123
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
124
|
+
return [r! / 255, g! / 255, b! / 255];
|
|
125
|
+
};
|
|
126
|
+
const luminance = ([r, g, b]: [number, number, number]) => {
|
|
127
|
+
const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
128
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
129
|
+
};
|
|
130
|
+
const contrastOverBackground = (fill: string, background: string) => {
|
|
131
|
+
const [hi, lo] = [
|
|
132
|
+
luminance(toSrgbOverBackground(fill, background)),
|
|
133
|
+
luminance(toSrgbOverBackground(background, background)),
|
|
134
|
+
].sort((x, y) => y - x);
|
|
135
|
+
return (hi! + 0.05) / (lo! + 0.05);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Measure the SETTLED paint, not the frame the theme switched in. The preview's
|
|
139
|
+
// `ThemeBoundary` writes `data-theme` in an effect, after the canvas has already
|
|
140
|
+
// rendered once under the `:root` (light) fallback. The storybook Vitest project runs
|
|
141
|
+
// with `reducedMotion: "reduce"`, and the tokens' reduced-motion backstop clamps
|
|
142
|
+
// `transition-duration` to 0.01ms on EVERY element — whose `transition-property`
|
|
143
|
+
// defaults to `all` — so the light→dark swap becomes a one-frame colour transition.
|
|
144
|
+
// Read synchronously, `getComputedStyle` still returns the transition's START value
|
|
145
|
+
// (the light panel's white) while the node rects, mounted after the switch, are
|
|
146
|
+
// already dark: a false 2.47:1 in the dark theme only. Awaiting the running
|
|
147
|
+
// transitions on the panel and its rects reads what the browser actually paints.
|
|
148
|
+
await Promise.all(
|
|
149
|
+
minimap.getAnimations({ subtree: true }).map((animation) =>
|
|
150
|
+
// A transition superseded by another change rejects `finished`; the settled
|
|
151
|
+
// value is still what the readback below measures.
|
|
152
|
+
animation.finished.catch(() => undefined),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
const panelBackground = getComputedStyle(minimap).backgroundColor;
|
|
156
|
+
|
|
157
|
+
// The whole point of this helper is that a blank minimap must FAIL the check — lock
|
|
158
|
+
// that directly: a fully transparent fill composites to the panel background itself,
|
|
159
|
+
// so it can never satisfy the ≥3 contrast assertion below (would incorrectly read as
|
|
160
|
+
// 21:1 "black on white" if alpha were discarded, per the #409 review finding).
|
|
161
|
+
expect(
|
|
162
|
+
contrastOverBackground("rgba(0, 0, 0, 0)", panelBackground),
|
|
163
|
+
"a fully transparent fill must not be able to satisfy the contrast check",
|
|
164
|
+
).toBeLessThan(3);
|
|
165
|
+
|
|
166
|
+
const rects = minimap.querySelectorAll<SVGRectElement>(".react-flow__minimap-node");
|
|
167
|
+
for (const rect of rects) {
|
|
168
|
+
expect(rect.width.baseVal.value, "minimap node rect has zero width").toBeGreaterThan(0);
|
|
169
|
+
expect(rect.height.baseVal.value, "minimap node rect has zero height").toBeGreaterThan(0);
|
|
170
|
+
const fill = getComputedStyle(rect).fill;
|
|
171
|
+
expect(
|
|
172
|
+
contrastOverBackground(fill, panelBackground),
|
|
173
|
+
`minimap node fill (${fill}) is not distinguishable from the panel background (${panelBackground})`,
|
|
174
|
+
).toBeGreaterThanOrEqual(3);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
74
177
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import "@xyflow/react/dist/style.css";
|
|
3
3
|
import { type Edge } from "@xyflow/react";
|
|
4
|
+
import { expect, userEvent, waitFor } from "storybook/test";
|
|
4
5
|
import { CanvasShell } from "../canvas-shell";
|
|
5
6
|
import { FlowNode, type BrandFlowNode } from "./flow-node";
|
|
6
7
|
|
|
@@ -141,3 +142,153 @@ export const Connected: Story = {
|
|
|
141
142
|
);
|
|
142
143
|
},
|
|
143
144
|
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Regression lock for #312 — `FlowNode` was keyboard-focusable and painted NO
|
|
148
|
+
* focus indicator at all. React Flow puts `tabIndex`/`:focus-visible` on its
|
|
149
|
+
* own wrapper (`.react-flow__node`), one level above the `<div>` this
|
|
150
|
+
* component renders, so the lock reaches the real tab stop by keyboard alone
|
|
151
|
+
* (no synthetic `.focus()`) and reads RESOLVED computed styles on the
|
|
152
|
+
* component's own div — not just a class string — because a previous defect
|
|
153
|
+
* on this exact surface measured 0 changed pixels out of 540,000 while the
|
|
154
|
+
* class list looked correct.
|
|
155
|
+
*
|
|
156
|
+
* Two nodes: a plain one (proves focus alone paints an indicator) and a
|
|
157
|
+
* pre-selected one (proves the NEW focus outline is a distinct, additional
|
|
158
|
+
* layer over the EXISTING `selected` ring — the two never collapse into one
|
|
159
|
+
* ring, and `selected` on its own never gains the outline).
|
|
160
|
+
*/
|
|
161
|
+
export const FocusIndicator: Story = {
|
|
162
|
+
render: () => {
|
|
163
|
+
const nodes: BrandFlowNode[] = [
|
|
164
|
+
{
|
|
165
|
+
id: "plain",
|
|
166
|
+
type: "brand",
|
|
167
|
+
position: { x: 40, y: 40 },
|
|
168
|
+
data: { title: "Plain node" },
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: "chosen",
|
|
172
|
+
type: "brand",
|
|
173
|
+
position: { x: 320, y: 40 },
|
|
174
|
+
selected: true,
|
|
175
|
+
data: { kind: "Output", title: "Selected node", tone: "success" },
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
return (
|
|
179
|
+
<div className="h-[220px]">
|
|
180
|
+
<CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes} />
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
play: async ({ canvasElement }) => {
|
|
185
|
+
// Resolve ANY CSS colour string down to sRGB so a contrast ratio is a
|
|
186
|
+
// measurement, not an assumption — same helper `FlowWeightedEdge`'s
|
|
187
|
+
// `KeyboardFocus` lock uses for the edge half of this same fix family.
|
|
188
|
+
const toSrgb = (colour: string): [number, number, number] => {
|
|
189
|
+
const surface = document.createElement("canvas");
|
|
190
|
+
surface.width = 1;
|
|
191
|
+
surface.height = 1;
|
|
192
|
+
const ctx = surface.getContext("2d")!;
|
|
193
|
+
ctx.fillStyle = colour;
|
|
194
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
195
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
196
|
+
return [r! / 255, g! / 255, b! / 255];
|
|
197
|
+
};
|
|
198
|
+
const luminance = ([r, g, b]: [number, number, number]) => {
|
|
199
|
+
const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
200
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
201
|
+
};
|
|
202
|
+
const contrast = (a: string, b: string) => {
|
|
203
|
+
const [hi, lo] = [luminance(toSrgb(a)), luminance(toSrgb(b))].sort((x, y) => y - x);
|
|
204
|
+
return (hi! + 0.05) / (lo! + 0.05);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
let plainWrapper!: HTMLElement;
|
|
208
|
+
let plainDiv!: HTMLElement;
|
|
209
|
+
let chosenWrapper!: HTMLElement;
|
|
210
|
+
let chosenDiv!: HTMLElement;
|
|
211
|
+
await waitFor(() => {
|
|
212
|
+
const pw = canvasElement.querySelector<HTMLElement>('[data-testid="rf__node-plain"]');
|
|
213
|
+
const cw = canvasElement.querySelector<HTMLElement>('[data-testid="rf__node-chosen"]');
|
|
214
|
+
expect(pw).not.toBe(null);
|
|
215
|
+
expect(cw).not.toBe(null);
|
|
216
|
+
plainWrapper = pw!;
|
|
217
|
+
chosenWrapper = cw!;
|
|
218
|
+
plainDiv = pw!.querySelector<HTMLElement>("[data-tone]")!;
|
|
219
|
+
chosenDiv = cw!.querySelector<HTMLElement>("[data-tone]")!;
|
|
220
|
+
expect(plainDiv).not.toBe(null);
|
|
221
|
+
expect(chosenDiv).not.toBe(null);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Resting: NEITHER node paints the focus outline. The pre-selected node
|
|
225
|
+
// already shows its selection ring (driven by the `selected` prop, not a
|
|
226
|
+
// CSS state) — that ring is untouched by this fix and must stay exactly
|
|
227
|
+
// as it was.
|
|
228
|
+
const restingPlainShadow = getComputedStyle(plainDiv).boxShadow;
|
|
229
|
+
const restingChosenShadow = getComputedStyle(chosenDiv).boxShadow;
|
|
230
|
+
await expect(getComputedStyle(plainDiv).outlineStyle).toBe("none");
|
|
231
|
+
await expect(getComputedStyle(chosenDiv).outlineStyle).toBe("none");
|
|
232
|
+
await expect(restingChosenShadow).not.toBe("none");
|
|
233
|
+
|
|
234
|
+
// Reach the plain node by keyboard alone — real tab order, no synthetic .focus().
|
|
235
|
+
let guard = 0;
|
|
236
|
+
while (document.activeElement !== plainWrapper && guard < 40) {
|
|
237
|
+
await userEvent.tab();
|
|
238
|
+
guard += 1;
|
|
239
|
+
}
|
|
240
|
+
await expect(document.activeElement).toBe(plainWrapper);
|
|
241
|
+
|
|
242
|
+
// Focused, unselected: a RESOLVED computed value changed, not just a
|
|
243
|
+
// class string.
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
expect(getComputedStyle(plainDiv).outlineStyle).toBe("solid");
|
|
246
|
+
});
|
|
247
|
+
await expect(getComputedStyle(plainDiv).outlineWidth).toBe("1px");
|
|
248
|
+
await expect(getComputedStyle(plainDiv).boxShadow).not.toBe(restingPlainShadow);
|
|
249
|
+
|
|
250
|
+
// The compound indicator clears WCAG 1.4.11 (3:1) against the node's own
|
|
251
|
+
// ground — via AT LEAST ONE of its two layers. Per ADR 0027 Amendment 2
|
|
252
|
+
// the bar is `max(contrast(--ring, S), contrast(--ring-contour, S)) >= 3:1`:
|
|
253
|
+
// in `dark`, `--ring-contour` is a deliberate no-op aliased to
|
|
254
|
+
// `--background` because the ring layer alone already clears the bar, so
|
|
255
|
+
// checking the contour alone (rather than the max of both layers) would
|
|
256
|
+
// fail here even though the indicator is genuinely visible. This is a
|
|
257
|
+
// rendered-surface re-check of the guarantee `themes-contrast.test.ts`'s
|
|
258
|
+
// `INDICATOR_SURFACES` already locks at the token level for
|
|
259
|
+
// `--flow-node`/`--canvas` — not a new bar.
|
|
260
|
+
const nodeGround = getComputedStyle(plainDiv).backgroundColor;
|
|
261
|
+
const contourInk = getComputedStyle(plainDiv).outlineColor;
|
|
262
|
+
const ringInk = getComputedStyle(plainDiv).getPropertyValue("--ring").trim();
|
|
263
|
+
const contourRatio = contrast(contourInk, nodeGround);
|
|
264
|
+
const ringRatio = contrast(ringInk, nodeGround);
|
|
265
|
+
const bestRatio = Math.max(contourRatio, ringRatio);
|
|
266
|
+
await expect(
|
|
267
|
+
bestRatio,
|
|
268
|
+
`focus indicator vs node ground: contour ${contourInk} = ${contourRatio.toFixed(2)}:1, ring ${ringInk} = ${ringRatio.toFixed(2)}:1`,
|
|
269
|
+
).toBeGreaterThanOrEqual(3);
|
|
270
|
+
|
|
271
|
+
// Continue tabbing to the pre-selected node.
|
|
272
|
+
guard = 0;
|
|
273
|
+
while (document.activeElement !== chosenWrapper && guard < 40) {
|
|
274
|
+
await userEvent.tab();
|
|
275
|
+
guard += 1;
|
|
276
|
+
}
|
|
277
|
+
await expect(document.activeElement).toBe(chosenWrapper);
|
|
278
|
+
|
|
279
|
+
// Selected AND focused: the outline is the ADDITIONAL, distinguishing
|
|
280
|
+
// layer — the shared ring layer resolves to the exact same box-shadow the
|
|
281
|
+
// selection alone already painted, so "selected" never silently gains a
|
|
282
|
+
// second, indistinguishable ring; only the new outline signals focus.
|
|
283
|
+
await waitFor(() => {
|
|
284
|
+
expect(getComputedStyle(chosenDiv).outlineStyle).toBe("solid");
|
|
285
|
+
});
|
|
286
|
+
await expect(getComputedStyle(chosenDiv).boxShadow).toBe(restingChosenShadow);
|
|
287
|
+
|
|
288
|
+
// Blur restores the resting state.
|
|
289
|
+
await userEvent.tab();
|
|
290
|
+
await waitFor(() => {
|
|
291
|
+
expect(getComputedStyle(chosenDiv).outlineStyle).toBe("none");
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
};
|