@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.
- package/README.md +8 -8
- package/dist/index.d.ts +734 -20
- package/dist/index.js +976 -178
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- 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 +86 -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 +13 -3
- package/src/legend/index.ts +7 -1
- package/src/legend/legend.stories.tsx +126 -0
- package/src/legend/legend.test.tsx +169 -0
- package/src/legend/legend.tsx +214 -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,691 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import "@xyflow/react/dist/style.css";
|
|
4
|
+
import { useEdgesState } from "@xyflow/react";
|
|
5
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
6
|
+
import { edgePaths, endpointsOffHandles } from "../testing/edge-anchors";
|
|
7
|
+
import { CanvasShell } from "../canvas-shell";
|
|
8
|
+
import { FlowNode, type BrandFlowNode } from "../flow-node";
|
|
9
|
+
import { withWeightedEdgeAria } from "./edge-aria";
|
|
10
|
+
import { FlowWeightedEdge, type BrandFlowWeightedEdge } from "./flow-weighted-edge";
|
|
11
|
+
|
|
12
|
+
const nodeTypes = { brand: FlowNode };
|
|
13
|
+
const edgeTypes = { weighted: FlowWeightedEdge };
|
|
14
|
+
|
|
15
|
+
const meta = {
|
|
16
|
+
title: "Flow/FlowWeightedEdge",
|
|
17
|
+
component: FlowWeightedEdge,
|
|
18
|
+
tags: ["autodocs"],
|
|
19
|
+
parameters: {
|
|
20
|
+
layout: "fullscreen",
|
|
21
|
+
docs: {
|
|
22
|
+
description: {
|
|
23
|
+
component:
|
|
24
|
+
"A branded process-map edge whose stroke width scales with a weight value " +
|
|
25
|
+
"(frequency or volume) relative to the other edges sharing its scaleGroup, and can " +
|
|
26
|
+
"optionally colour the stroke by a second continuous value between " +
|
|
27
|
+
"--flow-edge-weak and --flow-edge-strong; a “back” variant renders dashed and " +
|
|
28
|
+
"routed clear of the forward edge it doubles back over. The plain `FlowEdge` draws " +
|
|
29
|
+
"every edge at the same fixed width and colour; reach for `FlowWeightedEdge` the " +
|
|
30
|
+
"moment volume or a second metric needs to be visible on the edge itself; see " +
|
|
31
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
} satisfies Meta<typeof FlowWeightedEdge>;
|
|
36
|
+
export default meta;
|
|
37
|
+
type Story = StoryObj<typeof meta>;
|
|
38
|
+
|
|
39
|
+
/** Every edge must terminate ON a handle dot — see `testing/edge-anchors`. */
|
|
40
|
+
async function expectAnchoredToHandles(canvasElement: HTMLElement, edgeCount: number) {
|
|
41
|
+
await waitFor(() => {
|
|
42
|
+
expect(edgePaths(canvasElement, "flow-weighted-edge")).toHaveLength(edgeCount);
|
|
43
|
+
expect(endpointsOffHandles(canvasElement, "flow-weighted-edge")).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A chain of five nodes, edges weighted 1/4/8/2/6 — stroke width scales per edge,
|
|
49
|
+
* min-maxed against the others. Every edge is run through `withWeightedEdgeAria`
|
|
50
|
+
* (#285) so its weight reaches assistive technology as its accessible name,
|
|
51
|
+
* naming both endpoints via the node's own title rather than its raw id.
|
|
52
|
+
*/
|
|
53
|
+
export const Weighted: Story = {
|
|
54
|
+
render: function WeightedStory() {
|
|
55
|
+
const nodes: BrandFlowNode[] = Array.from({ length: 5 }, (_, i) => ({
|
|
56
|
+
id: `n${i + 1}`,
|
|
57
|
+
type: "brand",
|
|
58
|
+
position: { x: 0, y: i * 110 },
|
|
59
|
+
data: { kind: "Step", title: `Step ${i + 1}` },
|
|
60
|
+
}));
|
|
61
|
+
const nameOf = (nodeId: string) => nodes.find((n) => n.id === nodeId)?.data.title ?? nodeId;
|
|
62
|
+
const weights = [1, 4, 8, 2, 6];
|
|
63
|
+
// withWeightedEdgeAria returns a new array each call — memoized here so a
|
|
64
|
+
// re-render doesn't hand React Flow a new `edges` identity every frame.
|
|
65
|
+
const initialEdges = useMemo(
|
|
66
|
+
() =>
|
|
67
|
+
withWeightedEdgeAria(
|
|
68
|
+
weights.slice(0, 4).map(
|
|
69
|
+
(w, i): BrandFlowWeightedEdge => ({
|
|
70
|
+
id: `e${i + 1}`,
|
|
71
|
+
source: `n${i + 1}`,
|
|
72
|
+
target: `n${i + 2}`,
|
|
73
|
+
type: "weighted",
|
|
74
|
+
data: { weight: w },
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
{ nameOf },
|
|
78
|
+
),
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- initial edges built once, deliberately mirroring useState's "lazy initial value" convention; nameOf/weights are stable for the story's lifetime
|
|
80
|
+
[],
|
|
81
|
+
);
|
|
82
|
+
const [edges, , onEdgesChange] = useEdgesState<BrandFlowWeightedEdge>(initialEdges);
|
|
83
|
+
return (
|
|
84
|
+
<div className="h-[520px]">
|
|
85
|
+
<CanvasShell
|
|
86
|
+
nodes={nodes}
|
|
87
|
+
edges={edges}
|
|
88
|
+
onEdgesChange={onEdgesChange}
|
|
89
|
+
nodeTypes={nodeTypes}
|
|
90
|
+
edgeTypes={edgeTypes}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
play: async ({ canvasElement }) => {
|
|
96
|
+
await expectAnchoredToHandles(canvasElement, 4);
|
|
97
|
+
|
|
98
|
+
// Regression lock for #285: every edge's accessible name states its
|
|
99
|
+
// weight, by exact string — a regex would happily match a wrong name.
|
|
100
|
+
const expected: Record<string, string> = {
|
|
101
|
+
e1: "Edge from Step 1 to Step 2, weight 1",
|
|
102
|
+
e2: "Edge from Step 2 to Step 3, weight 4",
|
|
103
|
+
e3: "Edge from Step 3 to Step 4, weight 8",
|
|
104
|
+
e4: "Edge from Step 4 to Step 5, weight 2",
|
|
105
|
+
};
|
|
106
|
+
for (const [id, name] of Object.entries(expected)) {
|
|
107
|
+
let group: Element | null = null;
|
|
108
|
+
await waitFor(() => {
|
|
109
|
+
group = canvasElement.querySelector(`[data-id="${id}"]`);
|
|
110
|
+
expect(group).not.toBe(null);
|
|
111
|
+
});
|
|
112
|
+
await expect(group as unknown as HTMLElement).toHaveAccessibleName(name);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Weighted edges that also carry an `EdgeLabelPill` (frequency + duration). Tab
|
|
119
|
+
* to reach a pill. Also run through `withWeightedEdgeAria` (#285) — the pill
|
|
120
|
+
* stays a separate tab stop with its own name, but the edge's own accessible
|
|
121
|
+
* name now states its weight too (deliberately heard twice: they are two
|
|
122
|
+
* different objects on the accessibility tree).
|
|
123
|
+
*/
|
|
124
|
+
export const WeightedWithLabels: Story = {
|
|
125
|
+
render: function WeightedWithLabelsStory() {
|
|
126
|
+
const nodes: BrandFlowNode[] = [
|
|
127
|
+
{
|
|
128
|
+
id: "a",
|
|
129
|
+
type: "brand",
|
|
130
|
+
position: { x: 0, y: 0 },
|
|
131
|
+
data: { kind: "Source", title: "Order placed" },
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "b",
|
|
135
|
+
type: "brand",
|
|
136
|
+
position: { x: 280, y: 0 },
|
|
137
|
+
data: { kind: "Step", title: "Picked" },
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "c",
|
|
141
|
+
type: "brand",
|
|
142
|
+
position: { x: 560, y: 0 },
|
|
143
|
+
data: { kind: "Step", title: "Shipped" },
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
const nameOf = (nodeId: string) => nodes.find((n) => n.id === nodeId)?.data.title ?? nodeId;
|
|
147
|
+
const initialEdges = useMemo(
|
|
148
|
+
() =>
|
|
149
|
+
withWeightedEdgeAria(
|
|
150
|
+
[
|
|
151
|
+
{
|
|
152
|
+
id: "e-a-b",
|
|
153
|
+
source: "a",
|
|
154
|
+
target: "b",
|
|
155
|
+
type: "weighted",
|
|
156
|
+
data: { weight: 9, label: "128×", secondaryLabel: "3.4d avg" },
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "e-b-c",
|
|
160
|
+
source: "b",
|
|
161
|
+
target: "c",
|
|
162
|
+
type: "weighted",
|
|
163
|
+
data: { weight: 3, label: "42×", secondaryLabel: "1.1d avg" },
|
|
164
|
+
},
|
|
165
|
+
] satisfies BrandFlowWeightedEdge[],
|
|
166
|
+
{ nameOf },
|
|
167
|
+
),
|
|
168
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- initial edges built once, deliberately mirroring useState's "lazy initial value" convention; nameOf is stable for the story's lifetime
|
|
169
|
+
[],
|
|
170
|
+
);
|
|
171
|
+
const [edges, , onEdgesChange] = useEdgesState<BrandFlowWeightedEdge>(initialEdges);
|
|
172
|
+
return (
|
|
173
|
+
<div className="h-[300px]">
|
|
174
|
+
<CanvasShell
|
|
175
|
+
nodes={nodes}
|
|
176
|
+
edges={edges}
|
|
177
|
+
onEdgesChange={onEdgesChange}
|
|
178
|
+
nodeTypes={nodeTypes}
|
|
179
|
+
edgeTypes={edgeTypes}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
play: async ({ canvasElement }) => {
|
|
185
|
+
// The edge's own accessible name — unaffected by, and separate from, the
|
|
186
|
+
// pill's own name asserted below.
|
|
187
|
+
let group: Element | null = null;
|
|
188
|
+
await waitFor(() => {
|
|
189
|
+
group = canvasElement.querySelector('[data-id="e-a-b"]');
|
|
190
|
+
expect(group).not.toBe(null);
|
|
191
|
+
});
|
|
192
|
+
await expect(group as unknown as HTMLElement).toHaveAccessibleName(
|
|
193
|
+
"Edge from Order placed to Picked, weight 9, 128× 3.4d avg",
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const canvas = within(canvasElement);
|
|
197
|
+
const pill = await canvas.findByRole("button", { name: "128× · 3.4d avg" });
|
|
198
|
+
|
|
199
|
+
// Reach it by keyboard alone — real tab order (through the canvas pane and
|
|
200
|
+
// the graph's own nodes/edges), not a synthetic .focus() call.
|
|
201
|
+
let guard = 0;
|
|
202
|
+
while (document.activeElement !== pill && guard < 40) {
|
|
203
|
+
await userEvent.tab();
|
|
204
|
+
guard += 1;
|
|
205
|
+
}
|
|
206
|
+
await expect(pill).toHaveFocus();
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `data.value` + `data.valueDomain` interpolate stroke colour from
|
|
212
|
+
* `--flow-edge-weak` to `--flow-edge-strong`. Every edge shares the same
|
|
213
|
+
* `weight: 4` — before #285, that uniform weight (and the colour ramp) never
|
|
214
|
+
* reached assistive technology; `withWeightedEdgeAria` now names both.
|
|
215
|
+
*/
|
|
216
|
+
export const ColourRamp: Story = {
|
|
217
|
+
render: function ColourRampStory() {
|
|
218
|
+
const steps = 5;
|
|
219
|
+
const nodes: BrandFlowNode[] = Array.from({ length: steps + 1 }, (_, i) => ({
|
|
220
|
+
id: `n${i + 1}`,
|
|
221
|
+
type: "brand",
|
|
222
|
+
position: { x: 0, y: i * 110 },
|
|
223
|
+
data: { kind: "Stage", title: `Stage ${i + 1}` },
|
|
224
|
+
}));
|
|
225
|
+
const nameOf = (nodeId: string) => nodes.find((n) => n.id === nodeId)?.data.title ?? nodeId;
|
|
226
|
+
const initialEdges = useMemo(
|
|
227
|
+
() =>
|
|
228
|
+
withWeightedEdgeAria(
|
|
229
|
+
Array.from(
|
|
230
|
+
{ length: steps },
|
|
231
|
+
(_, i): BrandFlowWeightedEdge => ({
|
|
232
|
+
id: `e${i + 1}`,
|
|
233
|
+
source: `n${i + 1}`,
|
|
234
|
+
target: `n${i + 2}`,
|
|
235
|
+
type: "weighted",
|
|
236
|
+
data: {
|
|
237
|
+
weight: 4,
|
|
238
|
+
value: i,
|
|
239
|
+
valueDomain: [0, steps - 1],
|
|
240
|
+
secondaryLabel: `avg ${i}d`,
|
|
241
|
+
},
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
{ nameOf },
|
|
245
|
+
),
|
|
246
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- initial edges built once, deliberately mirroring useState's "lazy initial value" convention; nameOf is stable for the story's lifetime
|
|
247
|
+
[],
|
|
248
|
+
);
|
|
249
|
+
const [edges, , onEdgesChange] = useEdgesState<BrandFlowWeightedEdge>(initialEdges);
|
|
250
|
+
return (
|
|
251
|
+
<div className="h-[600px]">
|
|
252
|
+
<CanvasShell
|
|
253
|
+
nodes={nodes}
|
|
254
|
+
edges={edges}
|
|
255
|
+
onEdgesChange={onEdgesChange}
|
|
256
|
+
nodeTypes={nodeTypes}
|
|
257
|
+
edgeTypes={edgeTypes}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Exact accessible names asserted below, per issue #285's naming contract
|
|
265
|
+
// (weight, plus value where present, plus the pill text where present) — an
|
|
266
|
+
// exact-string match, never a regex, per the accessibility rule.
|
|
267
|
+
const TEN_EDGES_EXPECTED_ARIA_LABELS: Record<string, string> = {
|
|
268
|
+
e1: "Edge from Node 1 to Node 2, weight 1, min 1×",
|
|
269
|
+
e2: "Edge from Node 2 to Node 3, weight 2",
|
|
270
|
+
e3: "Edge from Node 3 to Node 4, weight 3",
|
|
271
|
+
e4: "Edge from Node 4 to Node 5, weight 4, value 4",
|
|
272
|
+
e5: "Edge from Node 5 to Node 6, weight 5",
|
|
273
|
+
e6: "Edge from Node 6 to Node 7, weight 6",
|
|
274
|
+
e7: "Edge from Node 7 to Node 8, weight 7, value 7",
|
|
275
|
+
e8: "Edge from Node 8 to Node 9, weight 8",
|
|
276
|
+
e9: "Edge from Node 9 to Node 10, weight 9",
|
|
277
|
+
e10: "Edge from Node 10 to Node 11, weight 10, max 10×",
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Ten edges, weight 1..10 in one (default) scaleGroup — strokes must span
|
|
282
|
+
* exactly [1.5, 8]px, linear. Mixes in labels and a colour ramp on a few
|
|
283
|
+
* edges. Run through `withWeightedEdgeAria` (#285): all 10 announce their
|
|
284
|
+
* weight, the 2 that also carry `data.value` announce it too, and the 2
|
|
285
|
+
* labelled edges (`min`/`max`) keep their pill's own accessible name
|
|
286
|
+
* unchanged alongside the edge's new one.
|
|
287
|
+
*/
|
|
288
|
+
export const TenEdgesMixed: Story = {
|
|
289
|
+
render: function TenEdgesMixedStory() {
|
|
290
|
+
const nodes: BrandFlowNode[] = Array.from({ length: 11 }, (_, i) => ({
|
|
291
|
+
id: `n${i + 1}`,
|
|
292
|
+
type: "brand",
|
|
293
|
+
position: { x: 0, y: i * 90 },
|
|
294
|
+
data: { kind: "Node", title: `Node ${i + 1}` },
|
|
295
|
+
}));
|
|
296
|
+
const nameOf = (nodeId: string) => nodes.find((n) => n.id === nodeId)?.data.title ?? nodeId;
|
|
297
|
+
const initialEdges = useMemo(
|
|
298
|
+
() =>
|
|
299
|
+
withWeightedEdgeAria(
|
|
300
|
+
Array.from({ length: 10 }, (_, i): BrandFlowWeightedEdge => {
|
|
301
|
+
const weight = i + 1;
|
|
302
|
+
const extra =
|
|
303
|
+
i === 0
|
|
304
|
+
? { label: "min", secondaryLabel: "1×" }
|
|
305
|
+
: i === 9
|
|
306
|
+
? { label: "max", secondaryLabel: "10×" }
|
|
307
|
+
: i % 3 === 0
|
|
308
|
+
? { value: weight, valueDomain: [1, 10] as [number, number] }
|
|
309
|
+
: {};
|
|
310
|
+
return {
|
|
311
|
+
id: `e${i + 1}`,
|
|
312
|
+
source: `n${i + 1}`,
|
|
313
|
+
target: `n${i + 2}`,
|
|
314
|
+
type: "weighted",
|
|
315
|
+
data: { weight, ...extra },
|
|
316
|
+
};
|
|
317
|
+
}),
|
|
318
|
+
{ nameOf },
|
|
319
|
+
),
|
|
320
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- initial edges built once, deliberately mirroring useState's "lazy initial value" convention; nameOf is stable for the story's lifetime
|
|
321
|
+
[],
|
|
322
|
+
);
|
|
323
|
+
const [edges, , onEdgesChange] = useEdgesState<BrandFlowWeightedEdge>(initialEdges);
|
|
324
|
+
return (
|
|
325
|
+
<div className="h-[900px]">
|
|
326
|
+
<CanvasShell
|
|
327
|
+
nodes={nodes}
|
|
328
|
+
edges={edges}
|
|
329
|
+
onEdgesChange={onEdgesChange}
|
|
330
|
+
nodeTypes={nodeTypes}
|
|
331
|
+
edgeTypes={edgeTypes}
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
},
|
|
336
|
+
play: async ({ canvasElement }) => {
|
|
337
|
+
// React Flow's layout/measurement pass paints edges asynchronously (it
|
|
338
|
+
// arrives in a lazy chunk and needs a frame to measure node handles) —
|
|
339
|
+
// reading .react-flow__edge-path once, synchronously, races that paint
|
|
340
|
+
// (~1-in-3 observed). Poll until all 10 have rendered, then re-read the
|
|
341
|
+
// (by-then-stable) widths inside the same retrying wait.
|
|
342
|
+
let paths: SVGPathElement[] = [];
|
|
343
|
+
await waitFor(() => {
|
|
344
|
+
paths = Array.from(canvasElement.querySelectorAll<SVGPathElement>(".react-flow__edge-path"));
|
|
345
|
+
expect(paths).toHaveLength(10);
|
|
346
|
+
});
|
|
347
|
+
await waitFor(() => {
|
|
348
|
+
const widths = paths.map((p) => parseFloat(p.style.strokeWidth));
|
|
349
|
+
expect(Math.min(...widths)).toBeCloseTo(1.5, 1);
|
|
350
|
+
expect(Math.max(...widths)).toBeCloseTo(8, 1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// #285 — every edge's accessible name states its weight (and its value,
|
|
354
|
+
// where present), by exact string.
|
|
355
|
+
for (const [id, name] of Object.entries(TEN_EDGES_EXPECTED_ARIA_LABELS)) {
|
|
356
|
+
let group: Element | null = null;
|
|
357
|
+
await waitFor(() => {
|
|
358
|
+
group = canvasElement.querySelector(`[data-id="${id}"]`);
|
|
359
|
+
expect(group).not.toBe(null);
|
|
360
|
+
});
|
|
361
|
+
await expect(group as unknown as HTMLElement).toHaveAccessibleName(name);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Also reachable by keyboard: the "min"-labelled pill is a real tab stop,
|
|
365
|
+
// with its OWN name — untouched by the edge-level name asserted above.
|
|
366
|
+
const canvas = within(canvasElement);
|
|
367
|
+
const pill = await canvas.findByRole("button", { name: "min · 1×" });
|
|
368
|
+
let guard = 0;
|
|
369
|
+
while (document.activeElement !== pill && guard < 60) {
|
|
370
|
+
await userEvent.tab();
|
|
371
|
+
guard += 1;
|
|
372
|
+
}
|
|
373
|
+
await expect(pill).toHaveFocus();
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Contract lock for `edge-aria.ts`'s three naming clauses (#327) — NOT a
|
|
379
|
+
* visual demo (all three edges render identically; nothing here scales with
|
|
380
|
+
* weight). Each edge locks a distinct clause:
|
|
381
|
+
*
|
|
382
|
+
* - `e-composed` (clause 2) — weight only, name composed via `nameOf`.
|
|
383
|
+
* - `e-custom` (clause 1) — a caller-set `ariaLabel` always wins, even
|
|
384
|
+
* though the edge also carries `weight`/`value`. **Freebie, not a gap
|
|
385
|
+
* closed**: this path is already covered transitively (15 edges' worth,
|
|
386
|
+
* across `Weighted`/`WeightedWithLabels`/`TenEdgesMixed`) and by a
|
|
387
|
+
* stronger pure identity assertion (`edge-aria.test.ts`, "an explicit
|
|
388
|
+
* edge.ariaLabel always wins, untouched"). It rides along here only
|
|
389
|
+
* because the mount already has to happen for `e-bare`.
|
|
390
|
+
* - `e-bare` (clause 3 — THE LOCK) — carries none of
|
|
391
|
+
* `weight`/`value`/`label`/`secondaryLabel`, so `withWeightedEdgeAria`
|
|
392
|
+
* stamps nothing and the edge keeps whatever name React Flow's own
|
|
393
|
+
* `EdgeWrapper` gives it by default: `"Edge from <source> to <target>"`,
|
|
394
|
+
* interpolating the raw node ids. That string is produced entirely by
|
|
395
|
+
* `@xyflow/react`, not by this repo — see `edge-aria.ts:19-21`.
|
|
396
|
+
*
|
|
397
|
+
* **Why `nameOf` is load-bearing, not decoration.** Every node title below
|
|
398
|
+
* is lexically disjoint from its id (`n3` → "Third", `n4` → "Fourth"). That
|
|
399
|
+
* makes `"Edge from n3 to n4"` — the string this play function asserts for
|
|
400
|
+
* `e-bare` — UNPRODUCIBLE by this repo's own composer: `nameOf` can only
|
|
401
|
+
* ever emit display titles, never raw ids. So the only thing that could put
|
|
402
|
+
* that exact string in the DOM is React Flow itself. Without this, deleting
|
|
403
|
+
* the clause-3 early return in `edge-aria.ts` would make the seam compose
|
|
404
|
+
* `"Edge from n3 to n4"` from the raw ids too (no measures to append), and
|
|
405
|
+
* the assertion would stay green for the wrong reason — see #327's "Test to
|
|
406
|
+
* add" for the full trap. Do not "simplify" `nameOf` away.
|
|
407
|
+
*/
|
|
408
|
+
export const NamingContractEdges: Story = {
|
|
409
|
+
render: function NamingContractEdgesStory() {
|
|
410
|
+
const nodes: BrandFlowNode[] = [
|
|
411
|
+
{ id: "n1", type: "brand", position: { x: 0, y: 0 }, data: { kind: "Step", title: "Start" } },
|
|
412
|
+
{
|
|
413
|
+
id: "n2",
|
|
414
|
+
type: "brand",
|
|
415
|
+
position: { x: 280, y: 0 },
|
|
416
|
+
data: { kind: "Step", title: "Middle" },
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
id: "n3",
|
|
420
|
+
type: "brand",
|
|
421
|
+
position: { x: 560, y: 0 },
|
|
422
|
+
data: { kind: "Step", title: "Third" },
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
id: "n4",
|
|
426
|
+
type: "brand",
|
|
427
|
+
position: { x: 840, y: 0 },
|
|
428
|
+
data: { kind: "Step", title: "Fourth" },
|
|
429
|
+
},
|
|
430
|
+
];
|
|
431
|
+
const nameOf = (nodeId: string) => nodes.find((n) => n.id === nodeId)?.data.title ?? nodeId;
|
|
432
|
+
const initialEdges = useMemo(
|
|
433
|
+
() =>
|
|
434
|
+
withWeightedEdgeAria(
|
|
435
|
+
[
|
|
436
|
+
{
|
|
437
|
+
id: "e-composed",
|
|
438
|
+
source: "n1",
|
|
439
|
+
target: "n2",
|
|
440
|
+
type: "weighted",
|
|
441
|
+
data: { weight: 4 },
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
id: "e-custom",
|
|
445
|
+
source: "n2",
|
|
446
|
+
target: "n3",
|
|
447
|
+
type: "weighted",
|
|
448
|
+
ariaLabel: "Custom edge name",
|
|
449
|
+
data: { weight: 9, value: 2, valueDomain: [0, 10] },
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: "e-bare",
|
|
453
|
+
source: "n3",
|
|
454
|
+
target: "n4",
|
|
455
|
+
type: "weighted",
|
|
456
|
+
data: {},
|
|
457
|
+
},
|
|
458
|
+
] satisfies BrandFlowWeightedEdge[],
|
|
459
|
+
{ nameOf },
|
|
460
|
+
),
|
|
461
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- initial edges built once, deliberately mirroring useState's "lazy initial value" convention; nameOf is stable for the story's lifetime
|
|
462
|
+
[],
|
|
463
|
+
);
|
|
464
|
+
const [edges, , onEdgesChange] = useEdgesState<BrandFlowWeightedEdge>(initialEdges);
|
|
465
|
+
return (
|
|
466
|
+
<div className="h-[300px]">
|
|
467
|
+
<CanvasShell
|
|
468
|
+
nodes={nodes}
|
|
469
|
+
edges={edges}
|
|
470
|
+
onEdgesChange={onEdgesChange}
|
|
471
|
+
nodeTypes={nodeTypes}
|
|
472
|
+
edgeTypes={edgeTypes}
|
|
473
|
+
/>
|
|
474
|
+
</div>
|
|
475
|
+
);
|
|
476
|
+
},
|
|
477
|
+
play: async ({ canvasElement }) => {
|
|
478
|
+
// Exact accessible names, one per clause — a regex would happily match a
|
|
479
|
+
// polluted name and let the bug survive the test.
|
|
480
|
+
const expected: Record<string, string> = {
|
|
481
|
+
"e-composed": "Edge from Start to Middle, weight 4",
|
|
482
|
+
"e-custom": "Custom edge name",
|
|
483
|
+
"e-bare": "Edge from n3 to n4",
|
|
484
|
+
};
|
|
485
|
+
for (const [id, name] of Object.entries(expected)) {
|
|
486
|
+
let group: Element | null = null;
|
|
487
|
+
await waitFor(() => {
|
|
488
|
+
group = canvasElement.querySelector(`[data-id="${id}"]`);
|
|
489
|
+
expect(group).not.toBe(null);
|
|
490
|
+
});
|
|
491
|
+
await expect(group as unknown as HTMLElement).toHaveAccessibleName(name);
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Keyboard focus (#286). Every edge is a real tab stop, so tabbing onto one has
|
|
498
|
+
* to change what is drawn. The indicator is compound — a neutral `--foreground`
|
|
499
|
+
* contour with the `--ring` band inside it — because `--ring` alone measures
|
|
500
|
+
* 1.30:1 against `--canvas` in the `light` theme and would be a non-indicator
|
|
501
|
+
* there. Selection is a separate state: this story needs none of it.
|
|
502
|
+
*/
|
|
503
|
+
export const KeyboardFocus: Story = {
|
|
504
|
+
render: function KeyboardFocusStory() {
|
|
505
|
+
const nodes: BrandFlowNode[] = [
|
|
506
|
+
{ id: "a", type: "brand", position: { x: 0, y: 0 }, data: { kind: "Step", title: "First" } },
|
|
507
|
+
{
|
|
508
|
+
id: "b",
|
|
509
|
+
type: "brand",
|
|
510
|
+
position: { x: 0, y: 180 },
|
|
511
|
+
data: { kind: "Step", title: "Second" },
|
|
512
|
+
},
|
|
513
|
+
];
|
|
514
|
+
const [edges, , onEdgesChange] = useEdgesState<BrandFlowWeightedEdge>([
|
|
515
|
+
{ id: "e-a-b", source: "a", target: "b", type: "weighted", data: { weight: 4 } },
|
|
516
|
+
]);
|
|
517
|
+
return (
|
|
518
|
+
<div className="h-[380px]">
|
|
519
|
+
<CanvasShell
|
|
520
|
+
nodes={nodes}
|
|
521
|
+
edges={edges}
|
|
522
|
+
onEdgesChange={onEdgesChange}
|
|
523
|
+
nodeTypes={nodeTypes}
|
|
524
|
+
edgeTypes={edgeTypes}
|
|
525
|
+
/>
|
|
526
|
+
</div>
|
|
527
|
+
);
|
|
528
|
+
},
|
|
529
|
+
play: async ({ canvasElement }) => {
|
|
530
|
+
// Resolve ANY CSS colour string — including `oklch()`, which browsers now
|
|
531
|
+
// serialise verbatim from getComputedStyle — down to sRGB bytes, by letting
|
|
532
|
+
// the platform paint it. This is what makes the contrast number below a
|
|
533
|
+
// measurement rather than an assumption.
|
|
534
|
+
const toSrgb = (colour: string): [number, number, number] => {
|
|
535
|
+
const surface = document.createElement("canvas");
|
|
536
|
+
surface.width = 1;
|
|
537
|
+
surface.height = 1;
|
|
538
|
+
const ctx = surface.getContext("2d")!;
|
|
539
|
+
ctx.fillStyle = colour;
|
|
540
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
541
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
542
|
+
return [r! / 255, g! / 255, b! / 255];
|
|
543
|
+
};
|
|
544
|
+
const luminance = ([r, g, b]: [number, number, number]) => {
|
|
545
|
+
const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
546
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
547
|
+
};
|
|
548
|
+
const contrast = (a: string, b: string) => {
|
|
549
|
+
const [hi, lo] = [luminance(toSrgb(a)), luminance(toSrgb(b))].sort((x, y) => y - x);
|
|
550
|
+
return (hi! + 0.05) / (lo! + 0.05);
|
|
551
|
+
};
|
|
552
|
+
/** The nearest ancestor that actually paints a ground behind the edge. */
|
|
553
|
+
const groundOf = (el: Element): string => {
|
|
554
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
555
|
+
const bg = getComputedStyle(node).backgroundColor;
|
|
556
|
+
if (bg && bg !== "transparent" && !/^rgba\(0, 0, 0, 0\)$/.test(bg)) return bg;
|
|
557
|
+
}
|
|
558
|
+
return getComputedStyle(document.body).backgroundColor;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
let contour!: SVGPathElement;
|
|
562
|
+
let ring!: SVGPathElement;
|
|
563
|
+
let edgePath!: SVGPathElement;
|
|
564
|
+
await waitFor(() => {
|
|
565
|
+
const c = canvasElement.querySelector<SVGPathElement>(
|
|
566
|
+
'[data-slot="flow-edge-focus-contour"]',
|
|
567
|
+
);
|
|
568
|
+
const r = canvasElement.querySelector<SVGPathElement>('[data-slot="flow-edge-focus-ring"]');
|
|
569
|
+
const p = canvasElement.querySelector<SVGPathElement>(".react-flow__edge-path");
|
|
570
|
+
expect(c).not.toBe(null);
|
|
571
|
+
expect(r).not.toBe(null);
|
|
572
|
+
expect(p).not.toBe(null);
|
|
573
|
+
contour = c!;
|
|
574
|
+
ring = r!;
|
|
575
|
+
edgePath = p!;
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Unfocused: the indicator is not painted at all.
|
|
579
|
+
const restingStroke = getComputedStyle(edgePath).stroke;
|
|
580
|
+
await waitFor(() => {
|
|
581
|
+
expect(getComputedStyle(contour).opacity).toBe("0");
|
|
582
|
+
expect(getComputedStyle(ring).opacity).toBe("0");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const group = contour.closest("g.react-flow__edge")!;
|
|
586
|
+
|
|
587
|
+
// Reach the edge by keyboard alone — real tab order, no synthetic .focus().
|
|
588
|
+
let guard = 0;
|
|
589
|
+
while (
|
|
590
|
+
!(
|
|
591
|
+
document.activeElement instanceof Element &&
|
|
592
|
+
document.activeElement.matches("g.react-flow__edge")
|
|
593
|
+
) &&
|
|
594
|
+
guard < 40
|
|
595
|
+
) {
|
|
596
|
+
await userEvent.tab();
|
|
597
|
+
guard += 1;
|
|
598
|
+
}
|
|
599
|
+
await expect(document.activeElement).toBe(group);
|
|
600
|
+
|
|
601
|
+
// Focused: RESOLVED computed values differ, not just class strings.
|
|
602
|
+
await waitFor(() => {
|
|
603
|
+
expect(getComputedStyle(contour).opacity).toBe("1");
|
|
604
|
+
expect(getComputedStyle(ring).opacity).toBe("1");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// …and no selection was needed to get here (#286: the shipped `selected`
|
|
608
|
+
// recolour is a different state and could never fire in a controlled flow).
|
|
609
|
+
await expect(group.classList.contains("selected")).toBe(false);
|
|
610
|
+
|
|
611
|
+
// The compound indicator's neutral layer clears WCAG 1.4.11 (3:1) against
|
|
612
|
+
// the canvas ground it is drawn on, in whichever theme this run is pinned to.
|
|
613
|
+
const ground = groundOf(group);
|
|
614
|
+
const contourInk = getComputedStyle(contour).stroke;
|
|
615
|
+
const ratio = contrast(contourInk, ground);
|
|
616
|
+
await expect(
|
|
617
|
+
ratio,
|
|
618
|
+
`focus contour ${contourInk} vs canvas ${ground} = ${ratio.toFixed(2)}:1`,
|
|
619
|
+
).toBeGreaterThanOrEqual(3);
|
|
620
|
+
|
|
621
|
+
// The indicator is wider than the edge it wraps, so it reads as a halo.
|
|
622
|
+
const contourWidth = parseFloat(getComputedStyle(contour).strokeWidth);
|
|
623
|
+
const edgeWidth = parseFloat(getComputedStyle(edgePath).strokeWidth);
|
|
624
|
+
await expect(contourWidth).toBeGreaterThan(edgeWidth);
|
|
625
|
+
|
|
626
|
+
// The edge's own resting paint is untouched by focus — the ramp colour and
|
|
627
|
+
// the weight-driven width still say what they said before.
|
|
628
|
+
await expect(getComputedStyle(edgePath).stroke).toBe(restingStroke);
|
|
629
|
+
|
|
630
|
+
// The indicator is opacity + stroke only, never a shadow — so it survives
|
|
631
|
+
// the decoration dial's 8-10 range, which goes shadowless. Flip the dial on
|
|
632
|
+
// the element that actually governs this subtree and prove the flip took
|
|
633
|
+
// (`--decoration` really reads 10) before re-measuring.
|
|
634
|
+
const decorationHost = group.closest<HTMLElement>("[data-decoration]") ?? canvasElement;
|
|
635
|
+
const previousDecoration = decorationHost.getAttribute("data-decoration");
|
|
636
|
+
try {
|
|
637
|
+
decorationHost.setAttribute("data-decoration", "10");
|
|
638
|
+
await waitFor(() => {
|
|
639
|
+
expect(getComputedStyle(decorationHost).getPropertyValue("--decoration").trim()).toBe("10");
|
|
640
|
+
});
|
|
641
|
+
await expect(getComputedStyle(contour).opacity).toBe("1");
|
|
642
|
+
const decoratedRatio = contrast(getComputedStyle(contour).stroke, groundOf(group));
|
|
643
|
+
await expect(
|
|
644
|
+
decoratedRatio,
|
|
645
|
+
`focus contour at data-decoration="10" = ${decoratedRatio.toFixed(2)}:1`,
|
|
646
|
+
).toBeGreaterThanOrEqual(3);
|
|
647
|
+
} finally {
|
|
648
|
+
// These hosts are shared with every other story in this page, so the
|
|
649
|
+
// restore must survive a failing assertion — otherwise one red story
|
|
650
|
+
// repaints the rest of the file and the real failure is unfindable.
|
|
651
|
+
if (previousDecoration === null) decorationHost.removeAttribute("data-decoration");
|
|
652
|
+
else decorationHost.setAttribute("data-decoration", previousDecoration);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// #297 watch. The indicator must read LIVE token references, never a colour
|
|
656
|
+
// baked at render time — a memoised hex goes stale on a theme switch while
|
|
657
|
+
// every other colour on the same element updates. Flip `data-theme` on the
|
|
658
|
+
// element that actually GOVERNS this subtree (a guessed ancestor is a silent
|
|
659
|
+
// no-op whenever the decorator wrote the attribute nearer the story), then
|
|
660
|
+
// prove both that the flip took and that the resolved ink actually moved.
|
|
661
|
+
const themeHost = group.closest<HTMLElement>("[data-theme]");
|
|
662
|
+
await expect(themeHost).not.toBe(null);
|
|
663
|
+
const previousTheme = themeHost!.getAttribute("data-theme")!;
|
|
664
|
+
const otherTheme = previousTheme === "dark" ? "light" : "dark";
|
|
665
|
+
const inkBefore = getComputedStyle(contour).stroke;
|
|
666
|
+
try {
|
|
667
|
+
themeHost!.setAttribute("data-theme", otherTheme);
|
|
668
|
+
await waitFor(() => {
|
|
669
|
+
expect(themeHost!.getAttribute("data-theme")).toBe(otherTheme);
|
|
670
|
+
expect(getComputedStyle(contour).stroke).not.toBe(inkBefore);
|
|
671
|
+
});
|
|
672
|
+
// …and it is still an indicator in the theme we switched INTO.
|
|
673
|
+
const switchedRatio = contrast(getComputedStyle(contour).stroke, groundOf(group));
|
|
674
|
+
await expect(
|
|
675
|
+
switchedRatio,
|
|
676
|
+
`focus contour after a live switch to ${otherTheme} = ${switchedRatio.toFixed(2)}:1`,
|
|
677
|
+
).toBeGreaterThanOrEqual(3);
|
|
678
|
+
} finally {
|
|
679
|
+
themeHost!.setAttribute("data-theme", previousTheme);
|
|
680
|
+
}
|
|
681
|
+
await waitFor(() => {
|
|
682
|
+
expect(getComputedStyle(contour).stroke).toBe(inkBefore);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Blur restores the resting state.
|
|
686
|
+
await userEvent.tab();
|
|
687
|
+
await waitFor(() => {
|
|
688
|
+
expect(getComputedStyle(contour).opacity).toBe("0");
|
|
689
|
+
});
|
|
690
|
+
},
|
|
691
|
+
};
|