@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.
Files changed (68) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +738 -20
  3. package/dist/index.js +985 -182
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -7
  6. package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
  7. package/src/__contract__/legend.contract.test.tsx +49 -0
  8. package/src/canvas-shell/canvas-shell.tsx +116 -1
  9. package/src/canvas-shell/use-measured-nodes.ts +101 -0
  10. package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
  11. package/src/flow-button-edge/flow-button-edge.tsx +8 -10
  12. package/src/flow-edge/flow-edge.stories.tsx +20 -0
  13. package/src/flow-edge/flow-edge.tsx +10 -3
  14. package/src/flow-edge-path/flow-edge-path.tsx +149 -0
  15. package/src/flow-edge-path/index.ts +1 -0
  16. package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
  17. package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
  18. package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
  19. package/src/flow-group-node/flow-group-node.tsx +24 -8
  20. package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
  21. package/src/flow-handle/flow-handle-anchor.ts +36 -0
  22. package/src/flow-handle/index.ts +1 -0
  23. package/src/flow-layout/flow-layout.stories.tsx +2 -2
  24. package/src/flow-layout/flow-layout.test.tsx +91 -0
  25. package/src/flow-layout/flow-layout.ts +77 -1
  26. package/src/flow-layout/layout-graph.test.ts +83 -2
  27. package/src/flow-layout/layout-graph.ts +23 -15
  28. package/src/flow-mini-map/flow-mini-map.stories.tsx +103 -0
  29. package/src/flow-node/flow-node.stories.tsx +151 -0
  30. package/src/flow-node/flow-node.tsx +56 -1
  31. package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
  32. package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
  33. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
  34. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
  35. package/src/flow-self-loop-edge/index.ts +13 -0
  36. package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
  37. package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
  38. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
  39. package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
  40. package/src/flow-smart-edge/index.ts +5 -1
  41. package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
  42. package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
  43. package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
  44. package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
  45. package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
  46. package/src/flow-weighted-edge/edge-aria.ts +117 -0
  47. package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
  48. package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
  49. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
  50. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
  51. package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
  52. package/src/flow-weighted-edge/index.ts +18 -0
  53. package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
  54. package/src/flow-weighted-edge/weight-scale.ts +86 -0
  55. package/src/index.ts +9 -0
  56. package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
  57. package/src/inspector-panel/inspector-panel.test.tsx +20 -0
  58. package/src/inspector-panel/inspector-panel.tsx +22 -9
  59. package/src/legend/index.ts +7 -1
  60. package/src/legend/legend.stories.tsx +126 -0
  61. package/src/legend/legend.test.tsx +180 -0
  62. package/src/legend/legend.tsx +222 -3
  63. package/src/templates-flow-workspace.stories.tsx +1 -1
  64. package/src/testing/canvas-framing.test.ts +107 -0
  65. package/src/testing/canvas-framing.ts +396 -0
  66. package/src/testing/edge-anchors.ts +107 -0
  67. package/src/testing/index.ts +36 -0
  68. 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
+ };