@elabs-ai/components-flow 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/index.d.ts +738 -20
- package/dist/index.js +985 -182
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
- package/src/__contract__/legend.contract.test.tsx +49 -0
- package/src/canvas-shell/canvas-shell.tsx +116 -1
- package/src/canvas-shell/use-measured-nodes.ts +101 -0
- package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
- package/src/flow-button-edge/flow-button-edge.tsx +8 -10
- package/src/flow-edge/flow-edge.stories.tsx +20 -0
- package/src/flow-edge/flow-edge.tsx +10 -3
- package/src/flow-edge-path/flow-edge-path.tsx +149 -0
- package/src/flow-edge-path/index.ts +1 -0
- package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
- package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
- package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
- package/src/flow-group-node/flow-group-node.tsx +24 -8
- package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
- package/src/flow-handle/flow-handle-anchor.ts +36 -0
- package/src/flow-handle/index.ts +1 -0
- package/src/flow-layout/flow-layout.stories.tsx +2 -2
- package/src/flow-layout/flow-layout.test.tsx +91 -0
- package/src/flow-layout/flow-layout.ts +77 -1
- package/src/flow-layout/layout-graph.test.ts +83 -2
- package/src/flow-layout/layout-graph.ts +23 -15
- package/src/flow-mini-map/flow-mini-map.stories.tsx +103 -0
- package/src/flow-node/flow-node.stories.tsx +151 -0
- package/src/flow-node/flow-node.tsx +56 -1
- package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
- package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
- package/src/flow-self-loop-edge/index.ts +13 -0
- package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
- package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
- package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
- package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
- package/src/flow-smart-edge/index.ts +5 -1
- package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
- package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
- package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
- package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
- package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
- package/src/flow-weighted-edge/edge-aria.ts +117 -0
- package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
- package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
- package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
- package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
- package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
- package/src/flow-weighted-edge/index.ts +18 -0
- package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
- package/src/flow-weighted-edge/weight-scale.ts +86 -0
- package/src/index.ts +9 -0
- package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
- package/src/inspector-panel/inspector-panel.test.tsx +20 -0
- package/src/inspector-panel/inspector-panel.tsx +22 -9
- package/src/legend/index.ts +7 -1
- package/src/legend/legend.stories.tsx +126 -0
- package/src/legend/legend.test.tsx +180 -0
- package/src/legend/legend.tsx +222 -3
- package/src/templates-flow-workspace.stories.tsx +1 -1
- package/src/testing/canvas-framing.test.ts +107 -0
- package/src/testing/canvas-framing.ts +396 -0
- package/src/testing/edge-anchors.ts +107 -0
- package/src/testing/index.ts +36 -0
- package/src/zoom-controls/zoom-controls.tsx +1 -1
|
@@ -2,6 +2,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
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// @xyflow/react needs real layout/measurement — mock the engine and assert the
|
|
5
|
+
// brand component's own output, the same way `flow-weighted-edge.test.tsx`
|
|
6
|
+
// does. Real rendering + a11y are covered by the Storybook interaction tests.
|
|
7
|
+
//
|
|
8
|
+
// `vi.mock`'s factory is hoisted above every import, so the mock state lives in
|
|
9
|
+
// `vi.hoisted` to survive the hoist without a TDZ ReferenceError.
|
|
10
|
+
const { edgesBox, internalNodeBox } = vi.hoisted(() => ({
|
|
11
|
+
edgesBox: { current: [] as unknown[] },
|
|
12
|
+
internalNodeBox: { current: null as unknown },
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("@xyflow/react", () => {
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- a vi.mock factory is hoisted above imports; a lazy require avoids the TDZ a top-level import would hit
|
|
17
|
+
const React = require("react");
|
|
18
|
+
return {
|
|
19
|
+
BaseEdge: ({
|
|
20
|
+
id,
|
|
21
|
+
path,
|
|
22
|
+
style,
|
|
23
|
+
className,
|
|
24
|
+
...rest
|
|
25
|
+
}: {
|
|
26
|
+
id: string;
|
|
27
|
+
path: string;
|
|
28
|
+
style?: React.CSSProperties;
|
|
29
|
+
className?: string;
|
|
30
|
+
}) =>
|
|
31
|
+
React.createElement("svg", { "data-testid": "base-edge" }, [
|
|
32
|
+
React.createElement("path", { key: "p", d: path, id, style, className, ...rest }),
|
|
33
|
+
]),
|
|
34
|
+
// Real EdgeLabelRenderer portals into a fixed container; a passthrough is
|
|
35
|
+
// enough here since we only assert the brand component's own output.
|
|
36
|
+
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => children,
|
|
37
|
+
useEdges: () => edgesBox.current,
|
|
38
|
+
useInternalNode: () => internalNodeBox.current,
|
|
39
|
+
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
import type { EdgeProps } from "@xyflow/react";
|
|
44
|
+
import { FlowSelfLoopEdge, type BrandFlowSelfLoopEdge } from "./flow-self-loop-edge";
|
|
45
|
+
import { selfLoopHandleArc, selfLoopPath } from "./self-loop-geometry";
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
cleanup();
|
|
49
|
+
edgesBox.current = [];
|
|
50
|
+
internalNodeBox.current = null;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** A measured `InternalNode` stand-in: 200×60 box whose top-left is (100, 200). */
|
|
54
|
+
function measuredNode(data: unknown = { title: "Review" }) {
|
|
55
|
+
return {
|
|
56
|
+
id: "node-a",
|
|
57
|
+
data,
|
|
58
|
+
measured: { width: 200, height: 60 },
|
|
59
|
+
internals: { positionAbsolute: { x: 100, y: 200 } },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeEdgeProps(
|
|
64
|
+
overrides: Partial<EdgeProps<BrandFlowSelfLoopEdge>> = {},
|
|
65
|
+
): EdgeProps<BrandFlowSelfLoopEdge> {
|
|
66
|
+
return {
|
|
67
|
+
id: "loop-1",
|
|
68
|
+
type: "self-loop",
|
|
69
|
+
source: "node-a",
|
|
70
|
+
target: "node-a",
|
|
71
|
+
sourceX: 200,
|
|
72
|
+
sourceY: 260,
|
|
73
|
+
targetX: 200,
|
|
74
|
+
targetY: 200,
|
|
75
|
+
sourcePosition: "bottom" as EdgeProps["sourcePosition"],
|
|
76
|
+
targetPosition: "top" as EdgeProps["targetPosition"],
|
|
77
|
+
selected: false,
|
|
78
|
+
animated: false,
|
|
79
|
+
data: {},
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const edgePath = () => screen.getByTestId("base-edge").querySelector("path")!;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The arc the fixture should produce: the 200×60 card at (100, 200), entered and left at
|
|
88
|
+
* the two handle points `makeEdgeProps` supplies.
|
|
89
|
+
*/
|
|
90
|
+
const fixtureArc = (loopRadius = 28) =>
|
|
91
|
+
selfLoopHandleArc(
|
|
92
|
+
{
|
|
93
|
+
sourceX: 200,
|
|
94
|
+
sourceY: 260,
|
|
95
|
+
targetX: 200,
|
|
96
|
+
targetY: 200,
|
|
97
|
+
centerX: 200,
|
|
98
|
+
centerY: 230,
|
|
99
|
+
width: 200,
|
|
100
|
+
height: 60,
|
|
101
|
+
},
|
|
102
|
+
loopRadius,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
describe("FlowSelfLoopEdge", () => {
|
|
106
|
+
it("starts and ends ON the two handle points, clearing the measured box between them", () => {
|
|
107
|
+
internalNodeBox.current = measuredNode();
|
|
108
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
109
|
+
expect(edgePath()).toHaveAttribute("d", fixtureArc().path);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("falls back to the handle midpoint before the node is measured — never NaN", () => {
|
|
113
|
+
internalNodeBox.current = { ...measuredNode(), measured: { width: 0, height: 0 } };
|
|
114
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
115
|
+
const d = edgePath().getAttribute("d")!;
|
|
116
|
+
expect(d).not.toMatch(/NaN/);
|
|
117
|
+
expect(d).toBe(selfLoopPath({ centerX: 200, topY: 200 }, 28).path);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("honours data.loopRadius", () => {
|
|
121
|
+
internalNodeBox.current = measuredNode();
|
|
122
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { loopRadius: 60 } })} />);
|
|
123
|
+
expect(edgePath()).toHaveAttribute("d", fixtureArc(60).path);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("carries its meaning as real text for assistive tech, not only a data attribute", () => {
|
|
127
|
+
internalNodeBox.current = measuredNode();
|
|
128
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
129
|
+
const graphic = screen.getByRole("img", {
|
|
130
|
+
name: "Self-loop on Review — this step repeats",
|
|
131
|
+
});
|
|
132
|
+
expect(graphic).toBeInTheDocument();
|
|
133
|
+
// The data-slot is the test/styling seam, NOT the accessibility channel.
|
|
134
|
+
expect(graphic.querySelector('[data-slot="flow-self-loop-edge"]')).not.toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("names the node by id when its data carries no title, and accepts an override", () => {
|
|
138
|
+
internalNodeBox.current = measuredNode({});
|
|
139
|
+
const { unmount } = render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
140
|
+
expect(
|
|
141
|
+
screen.getByRole("img", { name: "Self-loop on node-a — this step repeats" }),
|
|
142
|
+
).toBeInTheDocument();
|
|
143
|
+
unmount();
|
|
144
|
+
|
|
145
|
+
internalNodeBox.current = measuredNode();
|
|
146
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { loopLabel: "Reworked 12 times" } })} />);
|
|
147
|
+
expect(screen.getByRole("img", { name: "Reworked 12 times" })).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("is distinguishable from a forward edge without colour — the shape is the signal", () => {
|
|
151
|
+
internalNodeBox.current = measuredNode();
|
|
152
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
153
|
+
const path = edgePath();
|
|
154
|
+
// A cubic that returns to its own node: it leaves the bottom handle (200, 260) and
|
|
155
|
+
// re-enters at the top one (200, 200) — the two ends bracket the card rather than
|
|
156
|
+
// spanning a gap, which is what no forward edge ever does.
|
|
157
|
+
expect(path.getAttribute("d")).toMatch(/^M 200,260 C .* 200,200$/);
|
|
158
|
+
expect(path.style.stroke).toBe("var(--flow-edge)");
|
|
159
|
+
expect(path.style.fill).toBe("none");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("shares one weight scale with the forward edges around it", () => {
|
|
163
|
+
internalNodeBox.current = measuredNode();
|
|
164
|
+
edgesBox.current = [
|
|
165
|
+
{ id: "fwd-min", data: { weight: 1 } },
|
|
166
|
+
{ id: "loop-1", data: { weight: 10 } },
|
|
167
|
+
{ id: "fwd-mid", data: { weight: 5 } },
|
|
168
|
+
];
|
|
169
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { weight: 10 } })} />);
|
|
170
|
+
// Top of the shared [1.5, 8] range, because it is the heaviest edge present.
|
|
171
|
+
expect(edgePath().style.strokeWidth).toBe("8");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("uses the --ring token when selected, matching FlowNode's selected treatment", () => {
|
|
175
|
+
internalNodeBox.current = measuredNode();
|
|
176
|
+
render(<FlowSelfLoopEdge {...makeEdgeProps({ selected: true })} />);
|
|
177
|
+
expect(edgePath().style.stroke).toBe("var(--ring)");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("renders an EdgeLabelPill at the apex when labelled, and none when not", () => {
|
|
181
|
+
internalNodeBox.current = measuredNode();
|
|
182
|
+
const { unmount } = render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
|
|
183
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
184
|
+
unmount();
|
|
185
|
+
|
|
186
|
+
render(
|
|
187
|
+
<FlowSelfLoopEdge {...makeEdgeProps({ data: { label: "12×", secondaryLabel: "2.1d" } })} />,
|
|
188
|
+
);
|
|
189
|
+
const pill = screen.getByRole("button", { name: "12× · 2.1d" });
|
|
190
|
+
// The arc's widest point: 328 is the card's right edge (300) plus the 28px loop
|
|
191
|
+
// radius, and 230 is the card's own vertical centre — i.e. beside the node, not on it.
|
|
192
|
+
const { labelX, labelY } = fixtureArc();
|
|
193
|
+
expect([labelX, labelY]).toEqual([328, 230]);
|
|
194
|
+
expect(pill.parentElement!.style.transform).toContain(`translate(${labelX}px, ${labelY}px)`);
|
|
195
|
+
});
|
|
196
|
+
});
|