@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
2
3
|
import { Legend } from "./legend";
|
|
3
4
|
|
|
4
5
|
const meta = {
|
|
@@ -10,6 +11,8 @@ const meta = {
|
|
|
10
11
|
export default meta;
|
|
11
12
|
type Story = StoryObj<typeof meta>;
|
|
12
13
|
|
|
14
|
+
// ── Categorical (variant omitted — unchanged since before "scale" existed) ──
|
|
15
|
+
|
|
13
16
|
/** Default legend with a title and token-based color references. */
|
|
14
17
|
export const Default: Story = {
|
|
15
18
|
args: {
|
|
@@ -54,3 +57,126 @@ export const SingleItem: Story = {
|
|
|
54
57
|
items: [{ label: "Active node", color: "var(--primary)" }],
|
|
55
58
|
},
|
|
56
59
|
};
|
|
60
|
+
|
|
61
|
+
// ── Continuous scale (`variant="scale"`) ─────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Width ramp, default `ticks="minmax"` — min/max sample strokes drawn at the
|
|
65
|
+
* exact widths `computeEdgeWeightScale` (RM-043) would assign those weights,
|
|
66
|
+
* so the legend can never drift from a real flow's weighted edges.
|
|
67
|
+
*/
|
|
68
|
+
export const ScaleWidth: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
variant: "scale",
|
|
71
|
+
kind: "width",
|
|
72
|
+
domain: [2, 48],
|
|
73
|
+
},
|
|
74
|
+
play: async ({ canvas }) => {
|
|
75
|
+
const group = await canvas.findByRole("group", {
|
|
76
|
+
name: "Edge width scale, 2 to 48, minimum to maximum",
|
|
77
|
+
});
|
|
78
|
+
await waitFor(async () => {
|
|
79
|
+
expect(await canvas.findByText("2")).toBeInTheDocument();
|
|
80
|
+
expect(await canvas.findByText("48")).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// One tab stop for the whole reading key, not one per tick.
|
|
84
|
+
group.focus();
|
|
85
|
+
await expect(group).toHaveFocus();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** `ticks="minmedmax"` adds the domain midpoint as a third sample stroke. */
|
|
90
|
+
export const ScaleWidthMinMedMax: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
variant: "scale",
|
|
93
|
+
kind: "width",
|
|
94
|
+
domain: [2, 48],
|
|
95
|
+
ticks: "minmedmax",
|
|
96
|
+
},
|
|
97
|
+
play: async ({ canvas }) => {
|
|
98
|
+
await waitFor(async () => {
|
|
99
|
+
expect(await canvas.findByText("2")).toBeInTheDocument();
|
|
100
|
+
expect(await canvas.findByText("25")).toBeInTheDocument();
|
|
101
|
+
expect(await canvas.findByText("48")).toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Colour ramp — always a fixed 5-stop gradient built from
|
|
108
|
+
* `--flow-edge-weak`/`--flow-edge-strong`, labelled at every stop via
|
|
109
|
+
* `format`. Colour alone never carries the ordering (WCAG 1.4.1); the five
|
|
110
|
+
* numbered ticks are the required second channel.
|
|
111
|
+
*/
|
|
112
|
+
export const ScaleColor: Story = {
|
|
113
|
+
args: {
|
|
114
|
+
variant: "scale",
|
|
115
|
+
kind: "color",
|
|
116
|
+
domain: [0, 100],
|
|
117
|
+
format: (value: number) => `${value}%`,
|
|
118
|
+
},
|
|
119
|
+
play: async ({ canvas }) => {
|
|
120
|
+
await canvas.findByRole("group", {
|
|
121
|
+
name: "Edge color scale, 0% to 100%, minimum to maximum",
|
|
122
|
+
});
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(canvas.getByText("0%")).toBeInTheDocument();
|
|
125
|
+
expect(canvas.getByText("25%")).toBeInTheDocument();
|
|
126
|
+
expect(canvas.getByText("50%")).toBeInTheDocument();
|
|
127
|
+
expect(canvas.getByText("75%")).toBeInTheDocument();
|
|
128
|
+
expect(canvas.getByText("100%")).toBeInTheDocument();
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Zero-width domain (`min === max`) — every sample collapses onto one value.
|
|
135
|
+
* Must render without crashing and without a duplicate React key warning.
|
|
136
|
+
*/
|
|
137
|
+
export const ScaleWidthZeroDomain: Story = {
|
|
138
|
+
args: {
|
|
139
|
+
variant: "scale",
|
|
140
|
+
kind: "width",
|
|
141
|
+
domain: [10, 10],
|
|
142
|
+
ticks: "minmedmax",
|
|
143
|
+
},
|
|
144
|
+
play: async ({ canvas }) => {
|
|
145
|
+
await waitFor(async () => {
|
|
146
|
+
expect(await canvas.findAllByText("10")).toHaveLength(3);
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Domain spanning several orders of magnitude. */
|
|
152
|
+
export const ScaleColorWideRange: Story = {
|
|
153
|
+
args: {
|
|
154
|
+
variant: "scale",
|
|
155
|
+
kind: "color",
|
|
156
|
+
domain: [1, 1_000_000],
|
|
157
|
+
},
|
|
158
|
+
play: async ({ canvas }) => {
|
|
159
|
+
await waitFor(async () => {
|
|
160
|
+
expect(await canvas.findByText("1")).toBeInTheDocument();
|
|
161
|
+
expect(await canvas.findByText("1,000,000")).toBeInTheDocument();
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** A verbose custom `format` must truncate rather than break the layout. */
|
|
167
|
+
export const ScaleWidthLongLabels: Story = {
|
|
168
|
+
args: {
|
|
169
|
+
variant: "scale",
|
|
170
|
+
kind: "width",
|
|
171
|
+
domain: [2, 48],
|
|
172
|
+
ticks: "minmedmax",
|
|
173
|
+
format: (value: number) => `${value.toFixed(2)} transactions processed per hour on average`,
|
|
174
|
+
},
|
|
175
|
+
play: async ({ canvas }) => {
|
|
176
|
+
await waitFor(async () => {
|
|
177
|
+
expect(
|
|
178
|
+
await canvas.findByText("2.00 transactions processed per hour on average"),
|
|
179
|
+
).toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { LocaleProvider } from "@elabs-ai/components-ui";
|
|
1
2
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { computeEdgeWeightScale, type WeightedEdgeLike } from "../flow-weighted-edge/weight-scale";
|
|
3
5
|
import { Legend } from "./legend";
|
|
4
6
|
|
|
5
7
|
afterEach(cleanup);
|
|
@@ -43,4 +45,182 @@ describe("Legend", () => {
|
|
|
43
45
|
const list = screen.queryByRole("list");
|
|
44
46
|
expect(list).toBeInTheDocument();
|
|
45
47
|
});
|
|
48
|
+
|
|
49
|
+
// Same cases as above, but explicitly passing `variant="categorical"` — the
|
|
50
|
+
// additive prop must be a true no-op, not merely an omission that happens
|
|
51
|
+
// to work.
|
|
52
|
+
it('renders identically with variant="categorical" made explicit', () => {
|
|
53
|
+
render(<Legend variant="categorical" items={items} title="Legend Title" />);
|
|
54
|
+
expect(screen.getByText("Legend Title")).toBeInTheDocument();
|
|
55
|
+
expect(screen.getByText("Source")).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText("Target")).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Legend variant="scale", kind="width"', () => {
|
|
61
|
+
it("draws min/max sample strokes at exactly computeEdgeWeightScale's widths for that domain (#219)", () => {
|
|
62
|
+
const { container } = render(<Legend variant="scale" kind="width" domain={[2, 48]} />);
|
|
63
|
+
const expected = computeEdgeWeightScale([
|
|
64
|
+
{ id: "0", data: { weight: 2 } },
|
|
65
|
+
{ id: "1", data: { weight: 48 } },
|
|
66
|
+
] satisfies WeightedEdgeLike[]);
|
|
67
|
+
|
|
68
|
+
const lines = container.querySelectorAll("line");
|
|
69
|
+
expect(lines).toHaveLength(2);
|
|
70
|
+
expect(Number(lines[0]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("0")!, 5);
|
|
71
|
+
expect(Number(lines[1]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("1")!, 5);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('adds the domain midpoint as a third stroke under ticks="minmedmax", matching computeEdgeWeightScale for 2/25/48', () => {
|
|
75
|
+
const { container } = render(
|
|
76
|
+
<Legend variant="scale" kind="width" domain={[2, 48]} ticks="minmedmax" />,
|
|
77
|
+
);
|
|
78
|
+
const expected = computeEdgeWeightScale([
|
|
79
|
+
{ id: "0", data: { weight: 2 } },
|
|
80
|
+
{ id: "1", data: { weight: 25 } },
|
|
81
|
+
{ id: "2", data: { weight: 48 } },
|
|
82
|
+
] satisfies WeightedEdgeLike[]);
|
|
83
|
+
|
|
84
|
+
const lines = container.querySelectorAll("line");
|
|
85
|
+
expect(lines).toHaveLength(3);
|
|
86
|
+
expect(Number(lines[0]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("0")!, 5);
|
|
87
|
+
expect(Number(lines[1]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("1")!, 5);
|
|
88
|
+
expect(Number(lines[2]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("2")!, 5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders formatted min/max tick labels with tabular-nums", () => {
|
|
92
|
+
render(<Legend variant="scale" kind="width" domain={[2, 48]} />);
|
|
93
|
+
expect(screen.getByText("2")).toHaveClass("tabular-nums");
|
|
94
|
+
expect(screen.getByText("48")).toHaveClass("tabular-nums");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("applies a caller-supplied format function to every tick", () => {
|
|
98
|
+
render(
|
|
99
|
+
<Legend
|
|
100
|
+
variant="scale"
|
|
101
|
+
kind="width"
|
|
102
|
+
domain={[2, 48]}
|
|
103
|
+
ticks="minmedmax"
|
|
104
|
+
format={(v) => `${v} evt/s`}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
expect(screen.getByText("2 evt/s")).toBeInTheDocument();
|
|
108
|
+
expect(screen.getByText("25 evt/s")).toBeInTheDocument();
|
|
109
|
+
expect(screen.getByText("48 evt/s")).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("keeps the smallest tick's stroke at the readable floor (default 1.5px), never thinner", () => {
|
|
113
|
+
const { container } = render(<Legend variant="scale" kind="width" domain={[2, 1000]} />);
|
|
114
|
+
const lines = container.querySelectorAll("line");
|
|
115
|
+
const widths = Array.from(lines).map((l) => Number(l.getAttribute("stroke-width")));
|
|
116
|
+
expect(Math.min(...widths)).toBeCloseTo(1.5, 5);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("handles a zero-width domain (min === max) without crashing, all samples at the midpoint width", () => {
|
|
120
|
+
const { container } = render(
|
|
121
|
+
<Legend variant="scale" kind="width" domain={[10, 10]} ticks="minmedmax" />,
|
|
122
|
+
);
|
|
123
|
+
const lines = container.querySelectorAll("line");
|
|
124
|
+
expect(lines).toHaveLength(3);
|
|
125
|
+
const widths = Array.from(lines).map((l) => Number(l.getAttribute("stroke-width")));
|
|
126
|
+
// computeEdgeWeightScale's own zero-variance behavior: the midpoint of
|
|
127
|
+
// the default [1.5, 8] output range.
|
|
128
|
+
for (const w of widths) expect(w).toBeCloseTo(4.75, 5);
|
|
129
|
+
expect(screen.getAllByText("10")).toHaveLength(3);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("is a single keyboard tab stop with an aria-label summarizing the domain", () => {
|
|
133
|
+
render(<Legend variant="scale" kind="width" domain={[2, 48]} ticks="minmedmax" />);
|
|
134
|
+
const group = screen.getByRole("group", {
|
|
135
|
+
name: "Edge width scale, 2 to 48, minimum to maximum",
|
|
136
|
+
});
|
|
137
|
+
expect(group).toHaveAttribute("tabindex", "0");
|
|
138
|
+
// No descendant of the legend is itself a tab stop — one Tab reaches the
|
|
139
|
+
// whole reading key, not one per tick.
|
|
140
|
+
const innerTabbables = group.querySelectorAll(
|
|
141
|
+
"button, a[href], input, select, textarea, [tabindex]",
|
|
142
|
+
);
|
|
143
|
+
expect(innerTabbables).toHaveLength(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not render the categorical <ul> item list", () => {
|
|
147
|
+
const { container } = render(<Legend variant="scale" kind="width" domain={[2, 48]} />);
|
|
148
|
+
expect(container.querySelector("ul")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('Legend variant="scale", kind="color"', () => {
|
|
153
|
+
it("renders a 5-stop gradient using the flow-edge-weak/strong tokens", () => {
|
|
154
|
+
const { container } = render(<Legend variant="scale" kind="color" domain={[0, 100]} />);
|
|
155
|
+
const bar = container.querySelector("[aria-hidden='true'][style*='linear-gradient']");
|
|
156
|
+
expect(bar).not.toBeNull();
|
|
157
|
+
const backgroundImage = (bar as HTMLElement).style.backgroundImage;
|
|
158
|
+
expect(backgroundImage).toContain("var(--flow-edge-weak)");
|
|
159
|
+
expect(backgroundImage).toContain("var(--flow-edge-strong)");
|
|
160
|
+
// 5 explicit percentage stops: 0, 25, 50, 75, 100.
|
|
161
|
+
for (const pct of ["0%", "25%", "50%", "75%", "100%"]) {
|
|
162
|
+
expect(backgroundImage).toContain(pct);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("labels every one of the 5 stops via the caller's format function — colour is never the only channel (#387-style, WCAG 1.4.1)", () => {
|
|
167
|
+
render(<Legend variant="scale" kind="color" domain={[0, 100]} format={(v) => `${v}%`} />);
|
|
168
|
+
for (const label of ["0%", "25%", "50%", "75%", "100%"]) {
|
|
169
|
+
expect(screen.getByText(label)).toBeInTheDocument();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("uses the default locale number format when none is supplied", () => {
|
|
174
|
+
render(<Legend variant="scale" kind="color" domain={[0, 1000]} />);
|
|
175
|
+
expect(screen.getByText("0")).toBeInTheDocument();
|
|
176
|
+
expect(screen.getByText("250")).toBeInTheDocument();
|
|
177
|
+
expect(screen.getByText("500")).toBeInTheDocument();
|
|
178
|
+
expect(screen.getByText("750")).toBeInTheDocument();
|
|
179
|
+
expect(screen.getByText("1,000")).toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("handles a domain spanning several orders of magnitude", () => {
|
|
183
|
+
render(<Legend variant="scale" kind="color" domain={[1, 1_000_000]} />);
|
|
184
|
+
expect(screen.getByText("1")).toBeInTheDocument();
|
|
185
|
+
expect(screen.getByText("1,000,000")).toBeInTheDocument();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("handles a zero-width domain (min === max) — all 5 stop labels equal, no crash", () => {
|
|
189
|
+
render(<Legend variant="scale" kind="color" domain={[10, 10]} />);
|
|
190
|
+
expect(screen.getAllByText("10")).toHaveLength(5);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("truncates a very long formatted label instead of breaking layout", () => {
|
|
194
|
+
render(
|
|
195
|
+
<Legend
|
|
196
|
+
variant="scale"
|
|
197
|
+
kind="color"
|
|
198
|
+
domain={[0, 1]}
|
|
199
|
+
format={(v) => `${v} — a very long description of what this stop means in context`}
|
|
200
|
+
/>,
|
|
201
|
+
);
|
|
202
|
+
const label = screen.getByText(
|
|
203
|
+
"0 — a very long description of what this stop means in context",
|
|
204
|
+
);
|
|
205
|
+
expect(label).toHaveClass("truncate");
|
|
206
|
+
expect(label).toHaveClass("min-w-0");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("is a single keyboard tab stop with an aria-label summarizing the domain", () => {
|
|
210
|
+
render(<Legend variant="scale" kind="color" domain={[0, 100]} />);
|
|
211
|
+
const group = screen.getByRole("group", {
|
|
212
|
+
name: "Edge color scale, 0 to 100, minimum to maximum",
|
|
213
|
+
});
|
|
214
|
+
expect(group).toHaveAttribute("tabindex", "0");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("formats the default tick label with the active LocaleProvider locale, not the host locale", () => {
|
|
218
|
+
render(
|
|
219
|
+
<LocaleProvider locale="de-DE">
|
|
220
|
+
<Legend variant="scale" kind="color" domain={[0, 1000]} />
|
|
221
|
+
</LocaleProvider>,
|
|
222
|
+
);
|
|
223
|
+
// de-DE groups thousands with a period, never a comma.
|
|
224
|
+
expect(screen.getByText("1.000")).toBeInTheDocument();
|
|
225
|
+
});
|
|
46
226
|
});
|
package/src/legend/legend.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
1
2
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
3
|
+
import { computeEdgeWeightScale, type WeightedEdgeLike } from "../flow-weighted-edge/weight-scale";
|
|
2
4
|
|
|
3
5
|
export interface LegendItem {
|
|
4
6
|
label: string;
|
|
@@ -6,14 +8,67 @@ export interface LegendItem {
|
|
|
6
8
|
color: string;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Categorical legend props — a swatch-and-label list. This is the shape
|
|
13
|
+
* `Legend` renders when `variant` is omitted, byte-for-byte unchanged from
|
|
14
|
+
* before the `"scale"` variant existed.
|
|
15
|
+
*/
|
|
16
|
+
export interface LegendCategoricalProps {
|
|
17
|
+
variant?: "categorical";
|
|
10
18
|
items: LegendItem[];
|
|
11
19
|
title?: string;
|
|
12
20
|
className?: string;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Continuous scale legend — a reading key that explains a WIDTH or COLOR
|
|
25
|
+
* encoding as a *range*, not a set of discrete categories (e.g.
|
|
26
|
+
* `FlowWeightedEdge`'s `data.weight` → stroke width, `data.value` → stroke
|
|
27
|
+
* color). `kind: "width"` reuses `computeEdgeWeightScale` — the exact pure
|
|
28
|
+
* scale `FlowWeightedEdge` calls — so the sample strokes drawn here can never
|
|
29
|
+
* drift from the widths a real flow's edges render for the same weights.
|
|
30
|
+
*/
|
|
31
|
+
export interface LegendScaleProps {
|
|
32
|
+
variant: "scale";
|
|
33
|
+
/** Which continuous encoding this legend explains. */
|
|
34
|
+
kind: "width" | "color";
|
|
35
|
+
/** `[min, max]` of the underlying value the ramp represents. */
|
|
36
|
+
domain: [number, number];
|
|
37
|
+
/**
|
|
38
|
+
* Formats a domain value for display at a tick.
|
|
39
|
+
* @default the active `LocaleProvider` locale's `formatNumber` (host locale
|
|
40
|
+
* when no provider is mounted) — never a hardcoded `toLocaleString()`.
|
|
41
|
+
*/
|
|
42
|
+
format?: (value: number) => string;
|
|
43
|
+
/**
|
|
44
|
+
* Sample count for `kind: "width"`: `"minmax"` draws a min/max pair of
|
|
45
|
+
* sample strokes, `"minmedmax"` adds the domain midpoint as a third
|
|
46
|
+
* sample. `kind: "color"` always renders a fixed 5-stop gradient
|
|
47
|
+
* regardless of this prop — five stops is what makes a 2-endpoint color
|
|
48
|
+
* ramp legible as an ordered scale (see the color-ramp a11y note below),
|
|
49
|
+
* independent of how many width samples are shown.
|
|
50
|
+
* @default "minmax"
|
|
51
|
+
*/
|
|
52
|
+
ticks?: "minmax" | "minmedmax";
|
|
53
|
+
title?: string;
|
|
54
|
+
className?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type LegendProps = LegendCategoricalProps | LegendScaleProps;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Small legend mapping colors/types to labels for a canvas or chart
|
|
61
|
+
* (`variant: "categorical"`, the default), or — via `variant="scale"` — a
|
|
62
|
+
* continuous width/color ramp with a domain, tick marks and formatting.
|
|
63
|
+
*/
|
|
64
|
+
export function Legend(props: LegendProps) {
|
|
65
|
+
if (props.variant === "scale") {
|
|
66
|
+
return <LegendScale {...props} />;
|
|
67
|
+
}
|
|
68
|
+
return <LegendCategorical {...props} />;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function LegendCategorical({ items, title, className }: LegendCategoricalProps) {
|
|
17
72
|
return (
|
|
18
73
|
<div
|
|
19
74
|
className={cn(
|
|
@@ -37,3 +92,167 @@ export function Legend({ items, title, className }: LegendProps) {
|
|
|
37
92
|
</div>
|
|
38
93
|
);
|
|
39
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Width-ramp sample: a tick value plus the stroke width `computeEdgeWeightScale`
|
|
98
|
+
* assigns it. The "edges" fed to the scale are exactly the requested tick
|
|
99
|
+
* values, so the group's own min/max always equal the domain — the returned
|
|
100
|
+
* widths are byte-identical to what `computeEdgeWeightScale` would compute for
|
|
101
|
+
* real edges carrying these same weights (no duplicated min-max math).
|
|
102
|
+
*/
|
|
103
|
+
function computeWidthSamples(
|
|
104
|
+
domain: [number, number],
|
|
105
|
+
ticks: "minmax" | "minmedmax",
|
|
106
|
+
): { value: number; width: number }[] {
|
|
107
|
+
const [min, max] = domain;
|
|
108
|
+
const values = ticks === "minmedmax" ? [min, (min + max) / 2, max] : [min, max];
|
|
109
|
+
const likeEdges: WeightedEdgeLike[] = values.map((weight, index) => ({
|
|
110
|
+
id: String(index),
|
|
111
|
+
data: { weight },
|
|
112
|
+
}));
|
|
113
|
+
const widths = computeEdgeWeightScale(likeEdges);
|
|
114
|
+
return values.map((value, index) => ({ value, width: widths.get(String(index)) ?? 0 }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Fractions along the domain the color ramp samples — fixed at 5 stops (see `LegendScaleProps.ticks`). */
|
|
118
|
+
const COLOR_STOP_FRACTIONS = [0, 0.25, 0.5, 0.75, 1] as const;
|
|
119
|
+
|
|
120
|
+
function scaleKindLabel(kind: "width" | "color"): string {
|
|
121
|
+
return kind === "width" ? "width" : "color";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function LegendScale({
|
|
125
|
+
kind,
|
|
126
|
+
domain,
|
|
127
|
+
format,
|
|
128
|
+
ticks = "minmax",
|
|
129
|
+
title,
|
|
130
|
+
className,
|
|
131
|
+
}: LegendScaleProps) {
|
|
132
|
+
// The active `LocaleProvider` locale, not a hardcoded `toLocaleString()` —
|
|
133
|
+
// otherwise a `de-DE`-configured app still reads the domain ticks in the
|
|
134
|
+
// browser's host locale.
|
|
135
|
+
const { formatNumber } = useLocale();
|
|
136
|
+
const format_ = format ?? formatNumber;
|
|
137
|
+
const [min, max] = domain;
|
|
138
|
+
// e.g. "Edge width scale, 2 to 48, minimum to maximum" — a single accessible
|
|
139
|
+
// name for the whole reading key, since it is one keyboard tab stop, not one
|
|
140
|
+
// per tick.
|
|
141
|
+
const ariaLabel = `Edge ${scaleKindLabel(kind)} scale, ${format_(min)} to ${format_(max)}, minimum to maximum`;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
aria-label={ariaLabel}
|
|
146
|
+
className={cn(
|
|
147
|
+
"flex flex-col gap-2 rounded-lg bg-surface-elevated/90 p-3 shadow-ring-sm backdrop-blur",
|
|
148
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
149
|
+
className,
|
|
150
|
+
)}
|
|
151
|
+
role="group"
|
|
152
|
+
tabIndex={0}
|
|
153
|
+
>
|
|
154
|
+
{title ? <div className="text-body font-medium text-foreground">{title}</div> : null}
|
|
155
|
+
{kind === "width" ? (
|
|
156
|
+
<LegendScaleWidth domain={domain} format={format_} ticks={ticks} />
|
|
157
|
+
) : (
|
|
158
|
+
<LegendScaleColor domain={domain} format={format_} />
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function LegendScaleWidth({
|
|
165
|
+
domain,
|
|
166
|
+
format,
|
|
167
|
+
ticks,
|
|
168
|
+
}: {
|
|
169
|
+
domain: [number, number];
|
|
170
|
+
format: (value: number) => string;
|
|
171
|
+
ticks: "minmax" | "minmedmax";
|
|
172
|
+
}) {
|
|
173
|
+
const samples = computeWidthSamples(domain, ticks);
|
|
174
|
+
return (
|
|
175
|
+
<div className="flex flex-col gap-2">
|
|
176
|
+
{samples.map((sample, index) => (
|
|
177
|
+
// Index disambiguates a zero-width domain, where every sample shares
|
|
178
|
+
// the same value (and therefore the same width).
|
|
179
|
+
<div key={`${index}-${sample.value}`} className="flex items-center gap-3">
|
|
180
|
+
{/* The thin end of the ramp (default floor 1.5px) is the weakest mark
|
|
181
|
+
in the system — `--flow-edge` is the same token a real, unweighted
|
|
182
|
+
edge draws, and `strokeLinecap="round"` keeps that floor width from
|
|
183
|
+
disappearing into a hairline. */}
|
|
184
|
+
<svg aria-hidden="true" className="shrink-0" height={16} overflow="visible" width={40}>
|
|
185
|
+
<line
|
|
186
|
+
stroke="var(--flow-edge)"
|
|
187
|
+
strokeLinecap="round"
|
|
188
|
+
strokeWidth={sample.width}
|
|
189
|
+
x1={2}
|
|
190
|
+
x2={38}
|
|
191
|
+
y1={8}
|
|
192
|
+
y2={8}
|
|
193
|
+
/>
|
|
194
|
+
</svg>
|
|
195
|
+
<span className="min-w-0 truncate text-meta tabular-nums text-muted-foreground">
|
|
196
|
+
{format(sample.value)}
|
|
197
|
+
</span>
|
|
198
|
+
</div>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function LegendScaleColor({
|
|
205
|
+
domain,
|
|
206
|
+
format,
|
|
207
|
+
}: {
|
|
208
|
+
domain: [number, number];
|
|
209
|
+
format: (value: number) => string;
|
|
210
|
+
}) {
|
|
211
|
+
const [min, max] = domain;
|
|
212
|
+
const span = max - min;
|
|
213
|
+
const stops = COLOR_STOP_FRACTIONS.map((fraction) => ({
|
|
214
|
+
fraction,
|
|
215
|
+
value: min + fraction * span,
|
|
216
|
+
}));
|
|
217
|
+
// Explicit 5-stop `color-mix` gradient (interpolated `in oklch`, matching how
|
|
218
|
+
// the `--flow-edge-weak`/`--flow-edge-strong` tokens themselves are authored)
|
|
219
|
+
// rather than a bare 2-color `linear-gradient`, so the ramp's perceptual
|
|
220
|
+
// steps match the token authoring space instead of the browser's default
|
|
221
|
+
// sRGB gradient interpolation.
|
|
222
|
+
const gradient = `linear-gradient(to right, ${stops
|
|
223
|
+
.map(({ fraction }) => {
|
|
224
|
+
const percent = Math.round(fraction * 100);
|
|
225
|
+
return `color-mix(in oklch, var(--flow-edge-strong) ${percent}%, var(--flow-edge-weak)) ${percent}%`;
|
|
226
|
+
})
|
|
227
|
+
.join(", ")})`;
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div className="flex flex-col gap-1.5">
|
|
231
|
+
{/*
|
|
232
|
+
WCAG 1.4.1 — this gradient alone cannot carry the ordering, so the
|
|
233
|
+
numbered ticks below are the required second channel, not decoration.
|
|
234
|
+
*/}
|
|
235
|
+
<div
|
|
236
|
+
aria-hidden="true"
|
|
237
|
+
className="h-3 w-full rounded-full"
|
|
238
|
+
style={{ backgroundImage: gradient }}
|
|
239
|
+
/>
|
|
240
|
+
<div className="flex justify-between gap-1">
|
|
241
|
+
{stops.map((stop, index) => (
|
|
242
|
+
<span
|
|
243
|
+
// Index disambiguates a zero-width domain, where every stop shares
|
|
244
|
+
// the same formatted value.
|
|
245
|
+
key={`${index}-${stop.value}`}
|
|
246
|
+
className={cn(
|
|
247
|
+
"min-w-0 truncate text-meta tabular-nums text-muted-foreground",
|
|
248
|
+
index === 0 && "text-start",
|
|
249
|
+
index === stops.length - 1 && "text-end",
|
|
250
|
+
)}
|
|
251
|
+
>
|
|
252
|
+
{format(stop.value)}
|
|
253
|
+
</span>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* single source of truth: `pnpm gen:templates` derives the consumer template
|
|
5
5
|
* source (`docs/playbooks/templates/flow-workspace.tsx`) from it.
|
|
6
6
|
* Remember `import "@xyflow/react/dist/style.css"` is wired in Storybook preview.
|
|
7
|
-
* Verify across
|
|
7
|
+
* Verify across every theme with globals=theme:<slug>.
|
|
8
8
|
*/
|
|
9
9
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
10
10
|
import { useState } from "react";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one property of {@link waitForSettledCanvas} that is testable outside a browser:
|
|
3
|
+
* a canvas that is still MOVING is not settled, even when it measures as still.
|
|
4
|
+
*
|
|
5
|
+
* jsdom reports every box as 0×0, so the rect half of `canvasSignature` is constant here
|
|
6
|
+
* by construction — which is precisely the shape of the CI failure this locks. Two polls
|
|
7
|
+
* taken close enough together read the identical signature while the node-position
|
|
8
|
+
* transition is mid-flight (on a loaded runner, "close enough together" means inside one
|
|
9
|
+
* rendered frame), and the framing assertions then measure a picture in which React Flow
|
|
10
|
+
* has already drawn every edge at its final coordinate while the node elements, and with
|
|
11
|
+
* them the handle dots, are still sliding towards it — nine edges reported hanging up to
|
|
12
|
+
* 142 px off their dots on a canvas that is correct a fifth of a second later.
|
|
13
|
+
*/
|
|
14
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
15
|
+
import { waitForSettledCanvas } from "./canvas-framing";
|
|
16
|
+
|
|
17
|
+
type AnimationCapableElement = { getAnimations: () => Animation[] };
|
|
18
|
+
|
|
19
|
+
/** A canvas whose nodes already hold distinct laid-out positions. */
|
|
20
|
+
function canvasWithLaidOutNodes(): HTMLElement {
|
|
21
|
+
const canvas = document.createElement("div");
|
|
22
|
+
for (const [id, x] of [
|
|
23
|
+
["a", 0],
|
|
24
|
+
["b", 240],
|
|
25
|
+
] as const) {
|
|
26
|
+
const node = document.createElement("div");
|
|
27
|
+
node.className = "react-flow__node";
|
|
28
|
+
node.setAttribute("data-id", id);
|
|
29
|
+
node.style.transform = `translate(${x}px, 0px)`;
|
|
30
|
+
canvas.append(node);
|
|
31
|
+
}
|
|
32
|
+
document.body.append(canvas);
|
|
33
|
+
return canvas;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A finite, still-running animation whose end this test controls. */
|
|
37
|
+
function pendingAnimation(): { animation: Animation; finish: () => void } {
|
|
38
|
+
let finish = () => {};
|
|
39
|
+
const finished = new Promise<void>((resolve) => {
|
|
40
|
+
finish = () => resolve();
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
animation: {
|
|
44
|
+
playState: "running",
|
|
45
|
+
effect: { getTiming: () => ({ iterations: 1 }) },
|
|
46
|
+
finished,
|
|
47
|
+
} as unknown as Animation,
|
|
48
|
+
finish,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stubAnimations(read: () => Animation[]) {
|
|
53
|
+
(Element.prototype as unknown as AnimationCapableElement).getAnimations = read;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
delete (Element.prototype as unknown as Partial<AnimationCapableElement>).getAnimations;
|
|
58
|
+
document.body.innerHTML = "";
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("waitForSettledCanvas", () => {
|
|
62
|
+
it("does not settle while a finite animation is still running", async () => {
|
|
63
|
+
const canvas = canvasWithLaidOutNodes();
|
|
64
|
+
const { animation, finish } = pendingAnimation();
|
|
65
|
+
let running: Animation[] = [animation];
|
|
66
|
+
stubAnimations(() => running);
|
|
67
|
+
|
|
68
|
+
let settled = false;
|
|
69
|
+
const wait = waitForSettledCanvas(canvas, { timeout: 4_000, interval: 10 }).then(() => {
|
|
70
|
+
settled = true;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Long enough for many polls: under the old two-identical-polls rule this canvas —
|
|
74
|
+
// whose signature never changes in jsdom — settled on the second one.
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
76
|
+
expect(settled).toBe(false);
|
|
77
|
+
|
|
78
|
+
running = [];
|
|
79
|
+
finish();
|
|
80
|
+
await wait;
|
|
81
|
+
expect(settled).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("settles once nothing is animating", async () => {
|
|
85
|
+
const canvas = canvasWithLaidOutNodes();
|
|
86
|
+
stubAnimations(() => []);
|
|
87
|
+
await expect(
|
|
88
|
+
waitForSettledCanvas(canvas, { timeout: 4_000, interval: 10 }),
|
|
89
|
+
).resolves.toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("throws, naming the reason, when the nodes never take distinct positions", async () => {
|
|
93
|
+
const canvas = document.createElement("div");
|
|
94
|
+
for (const id of ["a", "b"]) {
|
|
95
|
+
const node = document.createElement("div");
|
|
96
|
+
node.className = "react-flow__node";
|
|
97
|
+
node.setAttribute("data-id", id);
|
|
98
|
+
node.style.transform = "translate(0px, 0px)";
|
|
99
|
+
canvas.append(node);
|
|
100
|
+
}
|
|
101
|
+
document.body.append(canvas);
|
|
102
|
+
stubAnimations(() => []);
|
|
103
|
+
await expect(waitForSettledCanvas(canvas, { timeout: 120, interval: 10 })).rejects.toThrow(
|
|
104
|
+
/layout has not run/,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|