@elabs-ai/components-flow 4.0.0 → 4.2.0

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