@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
|
@@ -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,89 @@ 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
|
+
const panelBackground = getComputedStyle(minimap).backgroundColor;
|
|
139
|
+
|
|
140
|
+
// The whole point of this helper is that a blank minimap must FAIL the check — lock
|
|
141
|
+
// that directly: a fully transparent fill composites to the panel background itself,
|
|
142
|
+
// so it can never satisfy the ≥3 contrast assertion below (would incorrectly read as
|
|
143
|
+
// 21:1 "black on white" if alpha were discarded, per the #409 review finding).
|
|
144
|
+
expect(
|
|
145
|
+
contrastOverBackground("rgba(0, 0, 0, 0)", panelBackground),
|
|
146
|
+
"a fully transparent fill must not be able to satisfy the contrast check",
|
|
147
|
+
).toBeLessThan(3);
|
|
148
|
+
|
|
149
|
+
const rects = minimap.querySelectorAll<SVGRectElement>(".react-flow__minimap-node");
|
|
150
|
+
for (const rect of rects) {
|
|
151
|
+
expect(rect.width.baseVal.value, "minimap node rect has zero width").toBeGreaterThan(0);
|
|
152
|
+
expect(rect.height.baseVal.value, "minimap node rect has zero height").toBeGreaterThan(0);
|
|
153
|
+
const fill = getComputedStyle(rect).fill;
|
|
154
|
+
expect(
|
|
155
|
+
contrastOverBackground(fill, panelBackground),
|
|
156
|
+
`minimap node fill (${fill}) is not distinguishable from the panel background (${panelBackground})`,
|
|
157
|
+
).toBeGreaterThanOrEqual(3);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
74
160
|
};
|
|
@@ -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
|
+
};
|
|
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
|
3
3
|
import { Star, type LucideIcon } from "lucide-react";
|
|
4
4
|
import { STATUS_TONE_ICONS } from "@elabs-ai/components-ui";
|
|
5
|
+
import { FLOW_HANDLE_ANCHOR_CLASS } from "../flow-handle/flow-handle-anchor";
|
|
5
6
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
7
|
|
|
7
8
|
/** A node side that can carry a handle. Doubles as the handle's stable id. */
|
|
@@ -45,6 +46,24 @@ export interface FlowNodeData extends Record<string, unknown> {
|
|
|
45
46
|
* bottom-source (unchanged, backward-compatible).
|
|
46
47
|
*/
|
|
47
48
|
handles?: FlowNodeHandles;
|
|
49
|
+
/**
|
|
50
|
+
* An extra row rendered INSIDE the card, below the text block — a meter bar, a
|
|
51
|
+
* sparkline, a chip row.
|
|
52
|
+
*
|
|
53
|
+
* It exists because content a composing package renders BESIDE `FlowNode` (as a sibling
|
|
54
|
+
* inside React Flow's node element) silently breaks the canvas's geometry: React Flow
|
|
55
|
+
* positions every `<Handle>` against the nearest positioned ancestor and measures the
|
|
56
|
+
* node box from its own wrapper, so a sibling row makes the node box taller than the
|
|
57
|
+
* visible card and the handles drift off the card's border by exactly that difference.
|
|
58
|
+
* Measured on the process map's activity node, whose 6px meter and 4px gap put every
|
|
59
|
+
* bottom dot 10px below the card it was supposed to sit on, and every left/right dot
|
|
60
|
+
* 5px below the card's own mid-line.
|
|
61
|
+
*
|
|
62
|
+
* Put the row here instead and the card IS the node box again, so the dots land on the
|
|
63
|
+
* card edge for free. Nothing is rendered when it is absent — existing nodes are
|
|
64
|
+
* byte-identical.
|
|
65
|
+
*/
|
|
66
|
+
footer?: ReactNode;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
export type BrandFlowNode = Node<FlowNodeData, "brand">;
|
|
@@ -101,11 +120,39 @@ const sidePosition: Record<FlowHandleSide, Position> = {
|
|
|
101
120
|
left: Position.Left,
|
|
102
121
|
};
|
|
103
122
|
|
|
104
|
-
|
|
123
|
+
// `FLOW_HANDLE_ANCHOR_CLASS` last: a connector dot must never be in flight when React
|
|
124
|
+
// Flow measures it. See `flow-handle/flow-handle-anchor.ts`.
|
|
125
|
+
const handleClassName = `!size-2 !border-2 !border-flow-edge !bg-flow-node ${FLOW_HANDLE_ANCHOR_CLASS}`;
|
|
105
126
|
|
|
106
127
|
/**
|
|
107
128
|
* Branded custom node. Register it in `nodeTypes={{ brand: FlowNode }}` and
|
|
108
129
|
* create nodes with `type: "brand"` and `data: FlowNodeData`.
|
|
130
|
+
*
|
|
131
|
+
* ## Focus vs selection (#312)
|
|
132
|
+
*
|
|
133
|
+
* `selected && "ring-2 ring-ring"` below is a SELECTION marker, not a focus
|
|
134
|
+
* indicator — it is React Flow's own click-driven `selected` state and is the
|
|
135
|
+
* genuine-selection carve-out `.claude/rules/theming.md` names explicitly.
|
|
136
|
+
* Keyboard focus is a separate, independent signal this component used to omit
|
|
137
|
+
* entirely (issue #312): React Flow puts `tabIndex`/`role="group"` and the real
|
|
138
|
+
* `:focus-visible` state on **its own wrapper** (`.react-flow__node`, which
|
|
139
|
+
* also always carries a `data-id` attribute), one level ABOVE the `<div>` this
|
|
140
|
+
* component returns — so neither `focus-ring` (`:focus-visible` on self) nor
|
|
141
|
+
* `focus-ring-within` (`:focus-within`, a focused descendant) can ever fire
|
|
142
|
+
* here; focus is PROXIED to an ancestor this component doesn't render.
|
|
143
|
+
* `focus-ring-static` is the flavour built for that exact shape (ADR 0027),
|
|
144
|
+
* gated by an ancestor-selector arbitrary variant — the same idiom
|
|
145
|
+
* `FlowEdgePath` uses for `.react-flow__edge:focus-visible`, keyed on
|
|
146
|
+
* `[data-id]` here (rather than the escaped `.react-flow\_\_node` class) so
|
|
147
|
+
* the selector needs no backslash escaping inside a plain JS string — a
|
|
148
|
+
* literal `\_` in a `cn()` argument is a real JS string escape and would be
|
|
149
|
+
* silently stripped at runtime (unlike in a bare, unbraced JSX attribute,
|
|
150
|
+
* where backslashes are never processed — the reason `FlowEdgePath` can use
|
|
151
|
+
* the class form safely and this component, composing through `cn()`, cannot).
|
|
152
|
+
* The two signals compose without merging into one ring: `selected` alone
|
|
153
|
+
* paints the ring layer only, while a focused node additionally gets the
|
|
154
|
+
* `--ring-contour` outline drawn outside it, so "selected AND focused" reads
|
|
155
|
+
* as two visible layers, not the single ring "selected alone" paints.
|
|
109
156
|
*/
|
|
110
157
|
export function FlowNode({
|
|
111
158
|
data,
|
|
@@ -117,11 +164,18 @@ export function FlowNode({
|
|
|
117
164
|
const ToneIcon = toneIcon[tone];
|
|
118
165
|
return (
|
|
119
166
|
<div
|
|
167
|
+
// The PAINTED card, and the box every handle dot must sit on the border of.
|
|
168
|
+
// `.react-flow__node` (the wrapper React Flow positions) can legitimately be
|
|
169
|
+
// taller than this — a composing package may render a badge or a meter beside
|
|
170
|
+
// the card — so a test that wants "is the connector on the card?" measures
|
|
171
|
+
// against this slot, never against the wrapper. See `testing/canvas-framing`.
|
|
172
|
+
data-slot="flow-node"
|
|
120
173
|
data-tone={tone}
|
|
121
174
|
className={cn(
|
|
122
175
|
"min-w-44 rounded-lg border bg-flow-node px-3 py-2 text-flow-node-foreground shadow-sm transition-[box-shadow,border-color] duration-fast ease-standard",
|
|
123
176
|
toneRing[tone],
|
|
124
177
|
selected && "ring-2 ring-ring",
|
|
178
|
+
"[[data-id]:focus-visible_&]:focus-ring-static",
|
|
125
179
|
)}
|
|
126
180
|
>
|
|
127
181
|
{data.handles ? (
|
|
@@ -184,6 +238,7 @@ export function FlowNode({
|
|
|
184
238
|
<ToneIcon aria-hidden="true" className={cn("size-3.5 shrink-0", toneIconColor[tone])} />
|
|
185
239
|
) : null}
|
|
186
240
|
</div>
|
|
241
|
+
{data.footer ? <div className="mt-2">{data.footer}</div> : null}
|
|
187
242
|
{toneLabel[tone] ? <span className="sr-only">{toneLabel[tone]}</span> : null}
|
|
188
243
|
</div>
|
|
189
244
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
|
2
2
|
import { Plus } from "lucide-react";
|
|
3
|
+
import { FLOW_HANDLE_ANCHOR_CLASS } from "../flow-handle/flow-handle-anchor";
|
|
3
4
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
4
5
|
|
|
5
6
|
export interface FlowPlaceholderNodeData extends Record<string, unknown> {
|
|
@@ -29,7 +30,9 @@ export function FlowPlaceholderNode({ data }: NodeProps<BrandFlowPlaceholderNode
|
|
|
29
30
|
<Handle
|
|
30
31
|
type="target"
|
|
31
32
|
position={Position.Top}
|
|
32
|
-
|
|
33
|
+
// `FLOW_HANDLE_ANCHOR_CLASS`: a connector dot must never be in flight when
|
|
34
|
+
// React Flow measures it. See `flow-handle/flow-handle-anchor.ts`.
|
|
35
|
+
className={`!size-2 !border-2 !border-flow-edge !bg-flow-node ${FLOW_HANDLE_ANCHOR_CLASS}`}
|
|
33
36
|
/>
|
|
34
37
|
<button
|
|
35
38
|
type="button"
|
|
@@ -38,7 +41,7 @@ export function FlowPlaceholderNode({ data }: NodeProps<BrandFlowPlaceholderNode
|
|
|
38
41
|
className={cn(
|
|
39
42
|
"flex min-w-44 items-center justify-center gap-1.5 rounded-lg border border-dashed border-flow-group-border bg-flow-group px-3 py-2 text-muted-foreground",
|
|
40
43
|
"transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground",
|
|
41
|
-
"focus-
|
|
44
|
+
"focus-ring",
|
|
42
45
|
)}
|
|
43
46
|
>
|
|
44
47
|
<Plus className="size-4" aria-hidden="true" />
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import "@xyflow/react/dist/style.css";
|
|
3
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
4
|
+
import { CanvasShell } from "../canvas-shell";
|
|
5
|
+
import { FlowNode, type BrandFlowNode } from "../flow-node";
|
|
6
|
+
import { layoutFlow } from "../flow-layout";
|
|
7
|
+
import { FlowWeightedEdge, type BrandFlowWeightedEdge } from "../flow-weighted-edge";
|
|
8
|
+
import { FlowSelfLoopEdge, type BrandFlowSelfLoopEdge } from "./flow-self-loop-edge";
|
|
9
|
+
|
|
10
|
+
const nodeTypes = { brand: FlowNode };
|
|
11
|
+
const edgeTypes = { weighted: FlowWeightedEdge, "self-loop": FlowSelfLoopEdge };
|
|
12
|
+
|
|
13
|
+
type ProcessEdge = BrandFlowWeightedEdge | BrandFlowSelfLoopEdge;
|
|
14
|
+
|
|
15
|
+
const meta = {
|
|
16
|
+
title: "Flow/FlowSelfLoopEdge",
|
|
17
|
+
component: FlowSelfLoopEdge,
|
|
18
|
+
tags: ["autodocs"],
|
|
19
|
+
parameters: {
|
|
20
|
+
layout: "fullscreen",
|
|
21
|
+
docs: {
|
|
22
|
+
description: {
|
|
23
|
+
component:
|
|
24
|
+
"A branded edge for the case where source and target are the same node — the " +
|
|
25
|
+
"this-step-repeated signal in a process map — drawn as a closed arc above the node " +
|
|
26
|
+
"instead of a straight or curved line between two points, since dagre cannot lay a " +
|
|
27
|
+
"self-loop out on its own. It shares its weight-to-stroke-width scale with " +
|
|
28
|
+
"`FlowWeightedEdge`, so reach for this one only for a genuine self-loop and the " +
|
|
29
|
+
"weighted edge for everything else; see " +
|
|
30
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
} satisfies Meta<typeof FlowSelfLoopEdge>;
|
|
35
|
+
export default meta;
|
|
36
|
+
type Story = StoryObj<typeof meta>;
|
|
37
|
+
|
|
38
|
+
/** Three steps of an order process, stacked top-to-bottom. */
|
|
39
|
+
function processNodes(): BrandFlowNode[] {
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
id: "a",
|
|
43
|
+
type: "brand",
|
|
44
|
+
position: { x: 0, y: 0 },
|
|
45
|
+
data: { kind: "Start", title: "Received" },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "b",
|
|
49
|
+
type: "brand",
|
|
50
|
+
position: { x: 0, y: 180 },
|
|
51
|
+
data: { kind: "Step", title: "Review" },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "c",
|
|
55
|
+
type: "brand",
|
|
56
|
+
position: { x: 0, y: 360 },
|
|
57
|
+
data: { kind: "End", title: "Approved" },
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every geometry read in these play functions sits inside `waitFor`/`findBy*`:
|
|
64
|
+
* React Flow arrives in a lazy chunk and paints its edges only after it has
|
|
65
|
+
* measured node handles, so a bare `querySelector` + expect races the paint.
|
|
66
|
+
*/
|
|
67
|
+
const loopPaths = (root: HTMLElement) =>
|
|
68
|
+
Array.from(root.querySelectorAll<SVGPathElement>('[data-slot="flow-self-loop-edge"]'));
|
|
69
|
+
const weightedPaths = (root: HTMLElement) =>
|
|
70
|
+
Array.from(root.querySelectorAll<SVGPathElement>('[data-slot="flow-weighted-edge"]'));
|
|
71
|
+
/** Browsers serialise `stroke-dasharray` as "6, 4"; jsdom keeps "6 4". Normalise both. */
|
|
72
|
+
const dashPattern = (path: SVGPathElement) =>
|
|
73
|
+
path.style.strokeDasharray.replace(/,/g, " ").replace(/\s+/g, " ").trim();
|
|
74
|
+
|
|
75
|
+
/** A step that repeats: the arc leaves the node's top-right and re-enters top-left. Tab to reach its label. */
|
|
76
|
+
export const SelfLoop: Story = {
|
|
77
|
+
render: () => {
|
|
78
|
+
const edges: ProcessEdge[] = [
|
|
79
|
+
{ id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 9 } },
|
|
80
|
+
{ id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 5 } },
|
|
81
|
+
{
|
|
82
|
+
id: "e-bb",
|
|
83
|
+
source: "b",
|
|
84
|
+
target: "b",
|
|
85
|
+
type: "self-loop",
|
|
86
|
+
data: { weight: 3, label: "12×", secondaryLabel: "2.1d avg" },
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
return (
|
|
90
|
+
<div className="h-[520px]">
|
|
91
|
+
<CanvasShell
|
|
92
|
+
nodes={processNodes()}
|
|
93
|
+
edges={edges}
|
|
94
|
+
nodeTypes={nodeTypes}
|
|
95
|
+
edgeTypes={edgeTypes}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
play: async ({ canvasElement }) => {
|
|
101
|
+
const canvas = within(canvasElement);
|
|
102
|
+
|
|
103
|
+
// The loop is a SHAPE, not a colour: it ends left of where it started and
|
|
104
|
+
// rises above the node's top edge, which no forward edge does.
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
const [loop] = loopPaths(canvasElement);
|
|
107
|
+
expect(loop).toBeDefined();
|
|
108
|
+
expect(loop!.getAttribute("d")).toMatch(/^M [\d.-]+,[\d.-]+ C .+$/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// …and the meaning also reaches assistive tech as real text.
|
|
112
|
+
await canvas.findByRole("img", { name: "Self-loop on Review — this step repeats" });
|
|
113
|
+
|
|
114
|
+
// The loop's label is a genuine keyboard tab stop, reached by tabbing —
|
|
115
|
+
// not by a synthetic .focus() call.
|
|
116
|
+
const pill = await canvas.findByRole("button", { name: "12× · 2.1d avg" });
|
|
117
|
+
let guard = 0;
|
|
118
|
+
while (document.activeElement !== pill && guard < 60) {
|
|
119
|
+
await userEvent.tab();
|
|
120
|
+
guard += 1;
|
|
121
|
+
}
|
|
122
|
+
await expect(pill).toHaveFocus();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** A rework edge running against the flow: dashed, and routed clear of the forward edge it doubles back over. */
|
|
127
|
+
export const BackEdge: Story = {
|
|
128
|
+
render: () => {
|
|
129
|
+
const edges: ProcessEdge[] = [
|
|
130
|
+
{ id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 9 } },
|
|
131
|
+
{ id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 6 } },
|
|
132
|
+
{
|
|
133
|
+
id: "e-cb",
|
|
134
|
+
source: "c",
|
|
135
|
+
target: "b",
|
|
136
|
+
type: "weighted",
|
|
137
|
+
data: { weight: 2, variant: "back", label: "18× reworked" },
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
return (
|
|
141
|
+
<div className="h-[520px]">
|
|
142
|
+
<CanvasShell
|
|
143
|
+
nodes={processNodes()}
|
|
144
|
+
edges={edges}
|
|
145
|
+
nodeTypes={nodeTypes}
|
|
146
|
+
edgeTypes={edgeTypes}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
play: async ({ canvasElement }) => {
|
|
152
|
+
const canvas = within(canvasElement);
|
|
153
|
+
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
const paths = weightedPaths(canvasElement);
|
|
156
|
+
expect(paths).toHaveLength(3);
|
|
157
|
+
const byVariant = (variant: string) => paths.filter((p) => p.dataset.variant === variant);
|
|
158
|
+
// Greyscale-safe: the back edge is dashed, the forward ones are not.
|
|
159
|
+
expect(byVariant("back")).toHaveLength(1);
|
|
160
|
+
expect(dashPattern(byVariant("back")[0]!)).toBe("6 4");
|
|
161
|
+
expect(byVariant("forward")).toHaveLength(2);
|
|
162
|
+
for (const forward of byVariant("forward")) {
|
|
163
|
+
expect(dashPattern(forward)).toBe("");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await canvas.findByRole("img", {
|
|
168
|
+
name: "Back edge — runs against the process direction",
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The acceptance fixture, laid out for real: `A → B → C` with `C → B` and a
|
|
175
|
+
* `B → B` loop. `layoutFlow` reports which edge went backwards and which is a
|
|
176
|
+
* self-loop, and the story picks the edge type from that metadata alone.
|
|
177
|
+
*/
|
|
178
|
+
export const ReworkLoopFromLayout: Story = {
|
|
179
|
+
render: () => {
|
|
180
|
+
const rawEdges: ProcessEdge[] = [
|
|
181
|
+
{ id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 9 } },
|
|
182
|
+
{ id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 6 } },
|
|
183
|
+
{
|
|
184
|
+
id: "e-cb",
|
|
185
|
+
source: "c",
|
|
186
|
+
target: "b",
|
|
187
|
+
type: "weighted",
|
|
188
|
+
data: { weight: 2, label: "18× reworked" },
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "e-bb",
|
|
192
|
+
source: "b",
|
|
193
|
+
target: "b",
|
|
194
|
+
type: "weighted",
|
|
195
|
+
data: { weight: 3, label: "12×" },
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
const { nodes, edges, backEdges, selfLoops } = layoutFlow(processNodes(), rawEdges, {
|
|
199
|
+
direction: "TB",
|
|
200
|
+
rankSpacing: 120,
|
|
201
|
+
});
|
|
202
|
+
const typed: ProcessEdge[] = edges.map((edge) => {
|
|
203
|
+
if (selfLoops.includes(edge.id)) {
|
|
204
|
+
return { ...edge, type: "self-loop" } as BrandFlowSelfLoopEdge;
|
|
205
|
+
}
|
|
206
|
+
if (backEdges.includes(edge.id)) {
|
|
207
|
+
return {
|
|
208
|
+
...edge,
|
|
209
|
+
data: { ...edge.data, variant: "back" },
|
|
210
|
+
} as BrandFlowWeightedEdge;
|
|
211
|
+
}
|
|
212
|
+
return edge;
|
|
213
|
+
});
|
|
214
|
+
return (
|
|
215
|
+
<div className="h-[560px]">
|
|
216
|
+
<CanvasShell nodes={nodes} edges={typed} nodeTypes={nodeTypes} edgeTypes={edgeTypes} />
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
},
|
|
220
|
+
play: async ({ canvasElement }) => {
|
|
221
|
+
const canvas = within(canvasElement);
|
|
222
|
+
|
|
223
|
+
// Three greyscale-distinct signatures on one canvas: solid forward strokes,
|
|
224
|
+
// a dashed back edge, and a looping arc.
|
|
225
|
+
await waitFor(() => {
|
|
226
|
+
const weighted = weightedPaths(canvasElement);
|
|
227
|
+
expect(weighted.filter((p) => p.dataset.variant === "forward")).toHaveLength(2);
|
|
228
|
+
expect(weighted.filter((p) => p.dataset.variant === "back")).toHaveLength(1);
|
|
229
|
+
expect(loopPaths(canvasElement)).toHaveLength(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await canvas.findByRole("img", { name: "Back edge — runs against the process direction" });
|
|
233
|
+
await canvas.findByRole("img", { name: "Self-loop on Review — this step repeats" });
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** The same loop at decoration 0 and 10 — the arc and the dashes survive the drafting ground. */
|
|
238
|
+
export const Decoration: Story = {
|
|
239
|
+
render: () => {
|
|
240
|
+
const edges: ProcessEdge[] = [
|
|
241
|
+
{ id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 9 } },
|
|
242
|
+
{ id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 5 } },
|
|
243
|
+
{
|
|
244
|
+
id: "e-cb",
|
|
245
|
+
source: "c",
|
|
246
|
+
target: "b",
|
|
247
|
+
type: "weighted",
|
|
248
|
+
data: { weight: 2, variant: "back" },
|
|
249
|
+
},
|
|
250
|
+
{ id: "e-bb", source: "b", target: "b", type: "self-loop", data: { weight: 3 } },
|
|
251
|
+
];
|
|
252
|
+
return (
|
|
253
|
+
<div className="grid h-[520px] grid-cols-2">
|
|
254
|
+
{([0, 10] as const).map((level) => (
|
|
255
|
+
<div key={level} data-decoration={level} className="h-full bg-background">
|
|
256
|
+
<CanvasShell
|
|
257
|
+
nodes={processNodes()}
|
|
258
|
+
edges={edges.map((edge) => ({ ...edge, id: `d${level}-${edge.id}` }))}
|
|
259
|
+
nodeTypes={nodeTypes}
|
|
260
|
+
edgeTypes={edgeTypes}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
},
|
|
267
|
+
play: async ({ canvasElement }) => {
|
|
268
|
+
await waitFor(() => {
|
|
269
|
+
expect(loopPaths(canvasElement)).toHaveLength(2);
|
|
270
|
+
expect(weightedPaths(canvasElement).filter((p) => p.dataset.variant === "back")).toHaveLength(
|
|
271
|
+
2,
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
};
|