@elabs-ai/components-flow 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/index.d.ts +734 -20
- package/dist/index.js +976 -178
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/canvas-shell/canvas-shell.tsx +116 -1
- package/src/canvas-shell/use-measured-nodes.ts +101 -0
- package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
- package/src/flow-button-edge/flow-button-edge.tsx +8 -10
- package/src/flow-edge/flow-edge.stories.tsx +20 -0
- package/src/flow-edge/flow-edge.tsx +10 -3
- package/src/flow-edge-path/flow-edge-path.tsx +149 -0
- package/src/flow-edge-path/index.ts +1 -0
- package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
- package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
- package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
- package/src/flow-group-node/flow-group-node.tsx +24 -8
- package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
- package/src/flow-handle/flow-handle-anchor.ts +36 -0
- package/src/flow-handle/index.ts +1 -0
- package/src/flow-layout/flow-layout.stories.tsx +2 -2
- package/src/flow-layout/flow-layout.test.tsx +91 -0
- package/src/flow-layout/flow-layout.ts +77 -1
- package/src/flow-layout/layout-graph.test.ts +83 -2
- package/src/flow-layout/layout-graph.ts +23 -15
- package/src/flow-mini-map/flow-mini-map.stories.tsx +86 -0
- package/src/flow-node/flow-node.stories.tsx +151 -0
- package/src/flow-node/flow-node.tsx +56 -1
- package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
- package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
- package/src/flow-self-loop-edge/index.ts +13 -0
- package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
- package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
- package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
- package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
- package/src/flow-smart-edge/index.ts +5 -1
- package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
- package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
- package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
- package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
- package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
- package/src/flow-weighted-edge/edge-aria.ts +117 -0
- package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
- package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
- package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
- package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
- package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
- package/src/flow-weighted-edge/index.ts +18 -0
- package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
- package/src/flow-weighted-edge/weight-scale.ts +86 -0
- package/src/index.ts +9 -0
- package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
- package/src/inspector-panel/inspector-panel.test.tsx +20 -0
- package/src/inspector-panel/inspector-panel.tsx +13 -3
- package/src/legend/index.ts +7 -1
- package/src/legend/legend.stories.tsx +126 -0
- package/src/legend/legend.test.tsx +169 -0
- package/src/legend/legend.tsx +214 -3
- package/src/templates-flow-workspace.stories.tsx +1 -1
- package/src/testing/canvas-framing.test.ts +107 -0
- package/src/testing/canvas-framing.ts +396 -0
- package/src/testing/edge-anchors.ts +107 -0
- package/src/testing/index.ts +36 -0
- package/src/zoom-controls/zoom-controls.tsx +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { backEdgeDetour, type BackEdgeNodeRect } from "./back-edge-geometry";
|
|
3
|
+
|
|
4
|
+
/** Three cards on one rank, the shape a rework edge has to get around. */
|
|
5
|
+
const rank: BackEdgeNodeRect[] = [
|
|
6
|
+
{ x: 0, y: 200, width: 176, height: 83 },
|
|
7
|
+
{ x: 220, y: 200, width: 176, height: 83 },
|
|
8
|
+
{ x: 440, y: 200, width: 176, height: 83 },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe("backEdgeDetour", () => {
|
|
12
|
+
it("clears the OUTERMOST card in the band, not just the edge's own two nodes", () => {
|
|
13
|
+
// The edge runs between the rank above (y 0) and the rank at y 200. Clearing only
|
|
14
|
+
// its own endpoints would put the leg at 396 + 40 — inside the third card.
|
|
15
|
+
expect(backEdgeDetour(rank, "vertical", [283, 100], 40)).toBe(656);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("ignores a card on another rank entirely", () => {
|
|
19
|
+
const withFarRank = [...rank, { x: 2000, y: 600, width: 176, height: 83 }];
|
|
20
|
+
expect(backEdgeDetour(withFarRank, "vertical", [283, 100], 40)).toBe(656);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("includes a card the span only touches at its edge", () => {
|
|
24
|
+
// Span ends exactly on the rank's top edge: the leg still has to pass it.
|
|
25
|
+
expect(backEdgeDetour(rank, "vertical", [100, 200], 40)).toBe(656);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("measures along the other axis in a left-to-right layout", () => {
|
|
29
|
+
// Ranks advance horizontally, so the return leg runs BELOW the cards.
|
|
30
|
+
expect(backEdgeDetour(rank, "horizontal", [0, 616], 40)).toBe(323);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("skips unmeasured cards rather than treating them as zero-size boxes at the origin", () => {
|
|
34
|
+
const unmeasured = [{ x: 900, y: 200, width: 0, height: 0 }, ...rank];
|
|
35
|
+
expect(backEdgeDetour(unmeasured, "vertical", [283, 100], 40)).toBe(656);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null when nothing is measured, so React Flow's own midpoint stands", () => {
|
|
39
|
+
expect(backEdgeDetour([], "vertical", [283, 100], 40)).toBeNull();
|
|
40
|
+
expect(
|
|
41
|
+
backEdgeDetour([{ x: 0, y: 0, width: 0, height: 0 }], "vertical", [0, 1], 40),
|
|
42
|
+
).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reads the span in either order", () => {
|
|
46
|
+
expect(backEdgeDetour(rank, "vertical", [100, 283], 40)).toBe(
|
|
47
|
+
backEdgeDetour(rank, "vertical", [283, 100], 40),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns null when every card sits outside the span", () => {
|
|
52
|
+
expect(backEdgeDetour(rank, "vertical", [400, 500], 40)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a back edge's return leg runs, so it stays VISIBLE.
|
|
3
|
+
*
|
|
4
|
+
* A back edge is drawn as a smoothstep with one long cross-segment running against the
|
|
5
|
+
* layout direction. React Flow puts that segment halfway between the two handles by
|
|
6
|
+
* default, and a fixed nudge off the midpoint does not help: in a top-down layout the
|
|
7
|
+
* midpoint of a rework edge sits between two ranks, i.e. squarely inside the column of
|
|
8
|
+
* cards it is supposed to run past. Edges paint UNDER nodes, so the segment vanishes
|
|
9
|
+
* behind the cards and all the reader is left with is a dashed stub below one node and
|
|
10
|
+
* another above the other — which reads as a stray rectangle, not as a loop back.
|
|
11
|
+
*
|
|
12
|
+
* So the leg is placed OUTSIDE every card it would otherwise pass behind: past the far
|
|
13
|
+
* side of every node whose extent overlaps the span the edge crosses. That is how a
|
|
14
|
+
* process-mining tool draws a rework loop — out, around the rank, back in — and it is
|
|
15
|
+
* the only placement that is correct for a graph rather than for a pair of nodes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** One node's laid-out box, in flow coordinates. */
|
|
19
|
+
export interface BackEdgeNodeRect {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Which axis the layout's ranks advance along. */
|
|
27
|
+
export type BackEdgeAxis = "vertical" | "horizontal";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The `centerX` (vertical layout) or `centerY` (horizontal layout) to hand
|
|
31
|
+
* `getSmoothStepPath`, or `null` when nothing is measured yet and React Flow's own
|
|
32
|
+
* midpoint should stand.
|
|
33
|
+
*
|
|
34
|
+
* @param rects Every laid-out node on the canvas. Nodes that cannot be hit are filtered
|
|
35
|
+
* out here rather than by the caller, so the caller stays a plain map.
|
|
36
|
+
* @param span The interval the edge crosses on the RANK axis (`[min, max]` of the two
|
|
37
|
+
* handle coordinates) — a node outside it is never behind this edge.
|
|
38
|
+
* @param clearance Gap left between the leg and the outermost card it clears.
|
|
39
|
+
*/
|
|
40
|
+
export function backEdgeDetour(
|
|
41
|
+
rects: readonly BackEdgeNodeRect[],
|
|
42
|
+
axis: BackEdgeAxis,
|
|
43
|
+
span: readonly [number, number],
|
|
44
|
+
clearance: number,
|
|
45
|
+
): number | null {
|
|
46
|
+
const [lo, hi] = span[0] <= span[1] ? span : [span[1], span[0]];
|
|
47
|
+
let far = Number.NEGATIVE_INFINITY;
|
|
48
|
+
|
|
49
|
+
for (const rect of rects) {
|
|
50
|
+
if (!(rect.width > 0) || !(rect.height > 0)) continue;
|
|
51
|
+
// Overlap on the RANK axis (the one the edge travels along): a node beside the
|
|
52
|
+
// corridor but on another rank cannot be crossed by this leg.
|
|
53
|
+
const [near, beyond] =
|
|
54
|
+
axis === "vertical" ? [rect.y, rect.y + rect.height] : [rect.x, rect.x + rect.width];
|
|
55
|
+
if (beyond < lo || near > hi) continue;
|
|
56
|
+
far = Math.max(far, axis === "vertical" ? rect.x + rect.width : rect.y + rect.height);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Number.isFinite(far) ? far + clearance : null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildWeightedEdgeAriaLabel, withWeightedEdgeAria } from "./edge-aria";
|
|
3
|
+
import type { BrandFlowWeightedEdge } from "./flow-weighted-edge";
|
|
4
|
+
|
|
5
|
+
/** Minimal edge factory — only the fields `buildWeightedEdgeAriaLabel` reads. */
|
|
6
|
+
function makeEdge(overrides: Partial<BrandFlowWeightedEdge> = {}): BrandFlowWeightedEdge {
|
|
7
|
+
return {
|
|
8
|
+
id: "e1",
|
|
9
|
+
source: "n1",
|
|
10
|
+
target: "n2",
|
|
11
|
+
type: "weighted",
|
|
12
|
+
data: {},
|
|
13
|
+
...overrides,
|
|
14
|
+
} as BrandFlowWeightedEdge;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("buildWeightedEdgeAriaLabel", () => {
|
|
18
|
+
it("composes weight only", () => {
|
|
19
|
+
const edge = makeEdge({ data: { weight: 4 } });
|
|
20
|
+
expect(buildWeightedEdgeAriaLabel(edge)).toBe("Edge from n1 to n2, weight 4");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("composes weight plus value", () => {
|
|
24
|
+
const edge = makeEdge({ data: { weight: 4, value: 7, valueDomain: [0, 10] } });
|
|
25
|
+
expect(buildWeightedEdgeAriaLabel(edge)).toBe("Edge from n1 to n2, weight 4, value 7");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("appends the pill text when label/secondaryLabel are present", () => {
|
|
29
|
+
const edge = makeEdge({ data: { weight: 1, label: "min", secondaryLabel: "1×" } });
|
|
30
|
+
expect(buildWeightedEdgeAriaLabel(edge)).toBe("Edge from n1 to n2, weight 1, min 1×");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("appends only the label present when the other pill field is absent", () => {
|
|
34
|
+
const edge = makeEdge({ data: { weight: 4, secondaryLabel: "avg 2d" } });
|
|
35
|
+
expect(buildWeightedEdgeAriaLabel(edge)).toBe("Edge from n1 to n2, weight 4, avg 2d");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("an explicit edge.ariaLabel always wins, untouched", () => {
|
|
39
|
+
const edge = makeEdge({
|
|
40
|
+
ariaLabel: "Custom name",
|
|
41
|
+
data: { weight: 4, value: 7, label: "x" },
|
|
42
|
+
});
|
|
43
|
+
expect(buildWeightedEdgeAriaLabel(edge)).toBe("Custom name");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns undefined when the edge carries no weight, value or labels — the untouched default", () => {
|
|
47
|
+
const edge = makeEdge({ data: {} });
|
|
48
|
+
expect(buildWeightedEdgeAriaLabel(edge)).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns undefined for an edge with no data at all", () => {
|
|
52
|
+
const edge = makeEdge({ data: undefined });
|
|
53
|
+
expect(buildWeightedEdgeAriaLabel(edge)).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("uses nameOf to resolve endpoint display names", () => {
|
|
57
|
+
const edge = makeEdge({ source: "n1", target: "n2", data: { weight: 2 } });
|
|
58
|
+
const nameOf = (id: string) => ({ n1: "Order placed", n2: "Picked" })[id]!;
|
|
59
|
+
expect(buildWeightedEdgeAriaLabel(edge, { nameOf })).toBe(
|
|
60
|
+
"Edge from Order placed to Picked, weight 2",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("honors weightLabel/valueLabel/formatNumber overrides", () => {
|
|
65
|
+
const edge = makeEdge({ data: { weight: 4.5, value: 2, valueDomain: [0, 10] } });
|
|
66
|
+
expect(
|
|
67
|
+
buildWeightedEdgeAriaLabel(edge, {
|
|
68
|
+
weightLabel: "frequency",
|
|
69
|
+
valueLabel: "duration",
|
|
70
|
+
formatNumber: (n) => n.toFixed(1),
|
|
71
|
+
}),
|
|
72
|
+
).toBe("Edge from n1 to n2, frequency 4.5, duration 2.0");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("withWeightedEdgeAria", () => {
|
|
77
|
+
it("stamps ariaLabel onto every edge that has none", () => {
|
|
78
|
+
const edges = [
|
|
79
|
+
makeEdge({ id: "e1", data: { weight: 1 } }),
|
|
80
|
+
makeEdge({ id: "e2", data: { weight: 8 } }),
|
|
81
|
+
];
|
|
82
|
+
const result = withWeightedEdgeAria(edges);
|
|
83
|
+
expect(result.map((e) => e.ariaLabel)).toEqual([
|
|
84
|
+
"Edge from n1 to n2, weight 1",
|
|
85
|
+
"Edge from n1 to n2, weight 8",
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("never overwrites a caller-supplied ariaLabel", () => {
|
|
90
|
+
const edges = [makeEdge({ id: "e1", ariaLabel: "Kept", data: { weight: 9 } })];
|
|
91
|
+
const result = withWeightedEdgeAria(edges);
|
|
92
|
+
expect(result[0]!.ariaLabel).toBe("Kept");
|
|
93
|
+
// Untouched — same reference, not just an equal string.
|
|
94
|
+
expect(result[0]).toBe(edges[0]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("leaves an edge with nothing to add untouched (identity preserved)", () => {
|
|
98
|
+
const edges = [makeEdge({ id: "e1", data: {} })];
|
|
99
|
+
const result = withWeightedEdgeAria(edges);
|
|
100
|
+
expect(result[0]!.ariaLabel).toBeUndefined();
|
|
101
|
+
expect(result[0]).toBe(edges[0]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns a new array", () => {
|
|
105
|
+
const edges = [makeEdge({ id: "e1", data: { weight: 1 } })];
|
|
106
|
+
expect(withWeightedEdgeAria(edges)).not.toBe(edges);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edge-aria — pure, framework-free "edge data → accessible name" naming seam
|
|
3
|
+
* for `FlowWeightedEdge`, mirroring `weight-scale.ts`'s shape (no React, no
|
|
4
|
+
* DOM, trivially unit-testable).
|
|
5
|
+
*
|
|
6
|
+
* `FlowWeightedEdge` encodes `data.weight` as stroke width and, optionally,
|
|
7
|
+
* `data.value` as stroke colour — both purely visual. React Flow's own
|
|
8
|
+
* `EdgeWrapper` sources an edge's accessible name from `edge.ariaLabel`
|
|
9
|
+
* (`edge` object, not the edge COMPONENT — see issue #285), so the component
|
|
10
|
+
* has no channel to set it. This module is the seam a caller runs their
|
|
11
|
+
* `edges` array through instead.
|
|
12
|
+
*
|
|
13
|
+
* Naming contract (a decision, not an accident):
|
|
14
|
+
* 1. An explicit `edge.ariaLabel` always wins — never overwritten.
|
|
15
|
+
* 2. Otherwise compose `"Edge from <source> to <target>, weight <n>"`,
|
|
16
|
+
* appending `", <valueLabel> <n>"` when `data.value` is set and the pill
|
|
17
|
+
* text (`data.label`/`data.secondaryLabel`, space-joined) when either is
|
|
18
|
+
* present.
|
|
19
|
+
* 3. When an edge carries none of `weight`/`value`/`label`/`secondaryLabel`,
|
|
20
|
+
* return `undefined` so React Flow's own default
|
|
21
|
+
* ("Edge from <source> to <target>") survives untouched. That quoted
|
|
22
|
+
* string is `@xyflow/react`'s output, not ours — the `e-bare` edge in
|
|
23
|
+
* `flow-weighted-edge.stories.tsx`'s `NamingContractEdges` story (#327)
|
|
24
|
+
* is the browser lock for it: it renders a measure-less edge against a
|
|
25
|
+
* real `CanvasShell` and asserts the default name via
|
|
26
|
+
* `toHaveAccessibleName`. If that story goes red after a dependency
|
|
27
|
+
* bump, read it as a signal that React Flow's default changed, not as a
|
|
28
|
+
* flake to silence.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { BrandFlowWeightedEdge } from "./flow-weighted-edge";
|
|
32
|
+
|
|
33
|
+
export interface WeightedEdgeAriaOptions {
|
|
34
|
+
/** Word used for the width measure in the composed name. @default "weight" */
|
|
35
|
+
weightLabel?: string;
|
|
36
|
+
/** Word used for the colour measure in the composed name. @default "value" */
|
|
37
|
+
valueLabel?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Map a node id to its display name, so the composed name says
|
|
40
|
+
* "Edge from Order placed to Picked" rather than "Edge from n1 to n2". The
|
|
41
|
+
* caller owns node display names (this module never reaches into a `nodes`
|
|
42
|
+
* array), so it is a hook, not an automatic lookup.
|
|
43
|
+
*/
|
|
44
|
+
nameOf?: (nodeId: string) => string;
|
|
45
|
+
/** Format a numeric measure for display. @default String(n) */
|
|
46
|
+
formatNumber?: (n: number) => string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_WEIGHT_LABEL = "weight";
|
|
50
|
+
const DEFAULT_VALUE_LABEL = "value";
|
|
51
|
+
|
|
52
|
+
function defaultNameOf(nodeId: string): string {
|
|
53
|
+
return nodeId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultFormatNumber(n: number): string {
|
|
57
|
+
return String(n);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Derive one edge's accessible name from its weight/value/labels. Pure — no
|
|
62
|
+
* React, no DOM. Returns:
|
|
63
|
+
* - the edge's own `ariaLabel`, unchanged, when the caller already set one;
|
|
64
|
+
* - a composed string when the edge carries `weight`, `value`, `label` or
|
|
65
|
+
* `secondaryLabel`;
|
|
66
|
+
* - `undefined` when there is nothing to add (so the default announcement
|
|
67
|
+
* from React Flow's `EdgeWrapper` survives untouched).
|
|
68
|
+
*/
|
|
69
|
+
export function buildWeightedEdgeAriaLabel(
|
|
70
|
+
edge: BrandFlowWeightedEdge,
|
|
71
|
+
opts: WeightedEdgeAriaOptions = {},
|
|
72
|
+
): string | undefined {
|
|
73
|
+
if (edge.ariaLabel) return edge.ariaLabel;
|
|
74
|
+
|
|
75
|
+
const { weight, value, label, secondaryLabel } = edge.data ?? {};
|
|
76
|
+
if (weight === undefined && value === undefined && !label && !secondaryLabel) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const {
|
|
81
|
+
weightLabel = DEFAULT_WEIGHT_LABEL,
|
|
82
|
+
valueLabel = DEFAULT_VALUE_LABEL,
|
|
83
|
+
nameOf = defaultNameOf,
|
|
84
|
+
formatNumber = defaultFormatNumber,
|
|
85
|
+
} = opts;
|
|
86
|
+
|
|
87
|
+
const parts = [`Edge from ${nameOf(edge.source)} to ${nameOf(edge.target)}`];
|
|
88
|
+
if (weight !== undefined) parts.push(`${weightLabel} ${formatNumber(weight)}`);
|
|
89
|
+
if (value !== undefined) parts.push(`${valueLabel} ${formatNumber(value)}`);
|
|
90
|
+
|
|
91
|
+
const pillText = [label, secondaryLabel].filter(Boolean).join(" ");
|
|
92
|
+
if (pillText) parts.push(pillText);
|
|
93
|
+
|
|
94
|
+
return parts.join(", ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Stamp `ariaLabel` onto every weighted edge that has none, per
|
|
99
|
+
* `buildWeightedEdgeAriaLabel`'s naming contract. An edge that already
|
|
100
|
+
* carries an `ariaLabel` is returned unchanged (never overwritten); an edge
|
|
101
|
+
* with nothing to add is also returned unchanged, so an unrelated edge type
|
|
102
|
+
* sharing this array keeps its identity.
|
|
103
|
+
*
|
|
104
|
+
* Returns a NEW array — memoize the call when used inline in render (e.g.
|
|
105
|
+
* `useMemo(() => withWeightedEdgeAria(edges), [edges])`), or a consumer hands
|
|
106
|
+
* React Flow a new `edges` identity every frame.
|
|
107
|
+
*/
|
|
108
|
+
export function withWeightedEdgeAria<E extends BrandFlowWeightedEdge>(
|
|
109
|
+
edges: E[],
|
|
110
|
+
opts?: WeightedEdgeAriaOptions,
|
|
111
|
+
): E[] {
|
|
112
|
+
return edges.map((edge) => {
|
|
113
|
+
if (edge.ariaLabel) return edge;
|
|
114
|
+
const ariaLabel = buildWeightedEdgeAriaLabel(edge, opts);
|
|
115
|
+
return ariaLabel === undefined ? edge : { ...edge, ariaLabel };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
vi.mock("@xyflow/react", () => ({
|
|
5
|
+
// Real EdgeLabelRenderer portals into a fixed container; a passthrough is
|
|
6
|
+
// enough here since we only assert the brand component's own output.
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
EdgeLabelRenderer: ({ children }: { children: any }) => children,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { EdgeLabelPill } from "./edge-label-pill";
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup);
|
|
14
|
+
|
|
15
|
+
describe("EdgeLabelPill", () => {
|
|
16
|
+
it("renders nothing when neither label nor secondaryLabel is set", () => {
|
|
17
|
+
const { container } = render(<EdgeLabelPill x={0} y={0} />);
|
|
18
|
+
expect(container).toBeEmptyDOMElement();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders a real, focusable button", () => {
|
|
22
|
+
render(<EdgeLabelPill x={0} y={0} label="128×" />);
|
|
23
|
+
const button = screen.getByRole("button");
|
|
24
|
+
expect(button.tagName).toBe("BUTTON");
|
|
25
|
+
expect(button).toHaveAttribute("type", "button");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("combines label + secondaryLabel into one accessible name", () => {
|
|
29
|
+
render(<EdgeLabelPill x={0} y={0} label="128×" secondaryLabel="3.4d avg" />);
|
|
30
|
+
expect(screen.getByRole("button", { name: "128× · 3.4d avg" })).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("uses only label when secondaryLabel is absent", () => {
|
|
34
|
+
render(<EdgeLabelPill x={0} y={0} label="128×" />);
|
|
35
|
+
expect(screen.getByRole("button", { name: "128×" })).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("uses only secondaryLabel when label is absent", () => {
|
|
39
|
+
render(<EdgeLabelPill x={0} y={0} secondaryLabel="3.4d avg" />);
|
|
40
|
+
expect(screen.getByRole("button", { name: "3.4d avg" })).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is pointer-clickable without a pointer-events-none ancestor blocking it", () => {
|
|
44
|
+
render(<EdgeLabelPill x={0} y={0} label="128×" />);
|
|
45
|
+
const button = screen.getByRole("button");
|
|
46
|
+
expect(button.className).toContain("pointer-events-auto");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// #351 — `className`/`...props` is the seam a composing package (e.g.
|
|
50
|
+
// `@elabs-ai/components-process`'s `ProcessTransitionEdge`) reaches this
|
|
51
|
+
// pill's root button through from outside `@elabs-ai/components-flow`,
|
|
52
|
+
// without a new semantic prop on this component.
|
|
53
|
+
it("merges a caller className onto the root button, after its own utility classes", () => {
|
|
54
|
+
render(<EdgeLabelPill x={0} y={0} label="128×" className="border-dashed" />);
|
|
55
|
+
const button = screen.getByRole("button");
|
|
56
|
+
expect(button.className).toContain("border-dashed");
|
|
57
|
+
// The pill's own classes are still present — className extends, not replaces.
|
|
58
|
+
expect(button.className).toContain("pointer-events-auto");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("spreads arbitrary props (e.g. data-selection) onto the root button", () => {
|
|
62
|
+
render(<EdgeLabelPill x={0} y={0} label="128×" data-selection="excluded" />);
|
|
63
|
+
expect(screen.getByRole("button")).toHaveAttribute("data-selection", "excluded");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import { EdgeLabelRenderer } from "@xyflow/react";
|
|
3
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
4
|
+
|
|
5
|
+
export interface EdgeLabelPillProps extends ComponentPropsWithoutRef<"button"> {
|
|
6
|
+
/** Primary label, e.g. a frequency count ("128×"). */
|
|
7
|
+
label?: string;
|
|
8
|
+
/** Secondary label rendered alongside the primary, e.g. a duration ("3.4d avg"). */
|
|
9
|
+
secondaryLabel?: string;
|
|
10
|
+
/** Label anchor, from `getBezierPath`/`getSmoothStepPath`'s `labelX`/`labelY`. */
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
/** Matches the parent edge's `selected` state. */
|
|
14
|
+
selected?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A small HTML pill (via `EdgeLabelRenderer`, not SVG `<text>`) anchored at an
|
|
19
|
+
* edge's label point — so it can theme, wrap, and carry two values, unlike a
|
|
20
|
+
* bare SVG text node. Renders nothing when neither label is set. Real
|
|
21
|
+
* `<button>` so it is a genuine keyboard tab stop with a visible focus ring;
|
|
22
|
+
* `pointer-events: auto` on an otherwise `nodrag nopan` wrapper so it doesn't
|
|
23
|
+
* drag/pan the canvas, and doesn't block hovering the edge underneath it (the
|
|
24
|
+
* wrapper is sized to the pill itself, not the whole edge).
|
|
25
|
+
*
|
|
26
|
+
* `className`/`...props` spread onto the root `<button>` (`data-slot="edge-label-pill"`)
|
|
27
|
+
* so a consumer that composes this pill from outside `@elabs-ai/components-flow` —
|
|
28
|
+
* `@elabs-ai/components-process`'s `ProcessTransitionEdge` is the reference caller —
|
|
29
|
+
* can reach it directly (a dashed frame, a `data-selection` attribute) without a new
|
|
30
|
+
* semantic prop on this component. `className` merges LAST via `cn()`, so a caller can
|
|
31
|
+
* override any of the pill's own utility classes; omitting both leaves every existing
|
|
32
|
+
* caller's rendered markup unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export function EdgeLabelPill({
|
|
35
|
+
label,
|
|
36
|
+
secondaryLabel,
|
|
37
|
+
x,
|
|
38
|
+
y,
|
|
39
|
+
selected,
|
|
40
|
+
className,
|
|
41
|
+
...props
|
|
42
|
+
}: EdgeLabelPillProps) {
|
|
43
|
+
if (!label && !secondaryLabel) return null;
|
|
44
|
+
|
|
45
|
+
const accessibleName = [label, secondaryLabel].filter(Boolean).join(" · ");
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<EdgeLabelRenderer>
|
|
49
|
+
<div
|
|
50
|
+
style={{
|
|
51
|
+
position: "absolute",
|
|
52
|
+
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
|
|
53
|
+
pointerEvents: "none",
|
|
54
|
+
}}
|
|
55
|
+
className="nodrag nopan"
|
|
56
|
+
data-slot="edge-label-pill-anchor"
|
|
57
|
+
>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
aria-label={accessibleName}
|
|
61
|
+
data-slot="edge-label-pill"
|
|
62
|
+
{...props}
|
|
63
|
+
className={cn(
|
|
64
|
+
"pointer-events-auto flex items-center gap-1 rounded-full border bg-flow-node px-2 py-0.5",
|
|
65
|
+
"text-meta font-medium text-flow-node-foreground shadow-sm",
|
|
66
|
+
"transition-colors duration-fast ease-standard",
|
|
67
|
+
"focus-ring",
|
|
68
|
+
selected ? "border-ring" : "border-flow-group-border",
|
|
69
|
+
className,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{label ? <span aria-hidden="true">{label}</span> : null}
|
|
73
|
+
{secondaryLabel ? (
|
|
74
|
+
<span aria-hidden="true" className="text-flow-node-foreground/70 tabular-nums">
|
|
75
|
+
{secondaryLabel}
|
|
76
|
+
</span>
|
|
77
|
+
) : null}
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</EdgeLabelRenderer>
|
|
81
|
+
);
|
|
82
|
+
}
|