@elabs-ai/components-flow 4.0.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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/index.d.ts +634 -0
- package/dist/index.js +1577 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
- package/src/canvas-shell/canvas-shell.stories.tsx +77 -0
- package/src/canvas-shell/canvas-shell.test.tsx +106 -0
- package/src/canvas-shell/canvas-shell.tsx +128 -0
- package/src/canvas-shell/index.ts +1 -0
- package/src/flow-button-edge/flow-button-edge.stories.tsx +151 -0
- package/src/flow-button-edge/flow-button-edge.test.tsx +99 -0
- package/src/flow-button-edge/flow-button-edge.tsx +80 -0
- package/src/flow-button-edge/index.ts +5 -0
- package/src/flow-edge/flow-edge.stories.tsx +79 -0
- package/src/flow-edge/flow-edge.test.tsx +76 -0
- package/src/flow-edge/flow-edge.tsx +34 -0
- package/src/flow-edge/index.ts +1 -0
- package/src/flow-floating-edge/floating-edge-geometry.test.ts +100 -0
- package/src/flow-floating-edge/floating-edge-geometry.ts +128 -0
- package/src/flow-floating-edge/flow-floating-edge.stories.tsx +81 -0
- package/src/flow-floating-edge/flow-floating-edge.tsx +82 -0
- package/src/flow-floating-edge/index.ts +6 -0
- package/src/flow-group-node/flow-group-node.stories.tsx +283 -0
- package/src/flow-group-node/flow-group-node.test.tsx +100 -0
- package/src/flow-group-node/flow-group-node.tsx +134 -0
- package/src/flow-group-node/index.ts +6 -0
- package/src/flow-layout/flow-layout.stories.tsx +215 -0
- package/src/flow-layout/flow-layout.test.tsx +133 -0
- package/src/flow-layout/flow-layout.ts +104 -0
- package/src/flow-layout/index.ts +14 -0
- package/src/flow-layout/layout-graph.test.ts +257 -0
- package/src/flow-layout/layout-graph.ts +302 -0
- package/src/flow-layout/use-auto-layout.ts +26 -0
- package/src/flow-layout/use-flow-layout.ts +60 -0
- package/src/flow-mini-map/flow-mini-map.stories.tsx +74 -0
- package/src/flow-mini-map/flow-mini-map.test.tsx +56 -0
- package/src/flow-mini-map/flow-mini-map.tsx +29 -0
- package/src/flow-mini-map/index.ts +1 -0
- package/src/flow-node/flow-node.stories.tsx +143 -0
- package/src/flow-node/flow-node.test.tsx +156 -0
- package/src/flow-node/flow-node.tsx +190 -0
- package/src/flow-node/index.ts +8 -0
- package/src/flow-placeholder-node/flow-placeholder-node.stories.tsx +260 -0
- package/src/flow-placeholder-node/flow-placeholder-node.test.tsx +101 -0
- package/src/flow-placeholder-node/flow-placeholder-node.tsx +49 -0
- package/src/flow-placeholder-node/index.ts +5 -0
- package/src/flow-smart-edge/flow-smart-edge.stories.tsx +75 -0
- package/src/flow-smart-edge/flow-smart-edge.tsx +75 -0
- package/src/flow-smart-edge/index.ts +9 -0
- package/src/flow-smart-edge/smart-edge-geometry.test.ts +120 -0
- package/src/flow-smart-edge/smart-edge-geometry.ts +116 -0
- package/src/helper-lines/get-helper-lines.ts +98 -0
- package/src/helper-lines/helper-lines.stories.tsx +107 -0
- package/src/helper-lines/helper-lines.test.tsx +78 -0
- package/src/helper-lines/helper-lines.tsx +67 -0
- package/src/helper-lines/index.ts +7 -0
- package/src/helper-lines/use-helper-lines.ts +109 -0
- package/src/index.ts +37 -0
- package/src/inspector-panel/index.ts +1 -0
- package/src/inspector-panel/inspector-panel.stories.tsx +161 -0
- package/src/inspector-panel/inspector-panel.test.tsx +82 -0
- package/src/inspector-panel/inspector-panel.tsx +129 -0
- package/src/legend/index.ts +1 -0
- package/src/legend/legend.stories.tsx +56 -0
- package/src/legend/legend.test.tsx +46 -0
- package/src/legend/legend.tsx +39 -0
- package/src/templates-flow-workspace.stories.tsx +169 -0
- package/src/use-flow-groups/group-operations.test.ts +318 -0
- package/src/use-flow-groups/group-operations.ts +349 -0
- package/src/use-flow-groups/index.ts +13 -0
- package/src/use-flow-groups/use-flow-groups.ts +113 -0
- package/src/zoom-controls/index.ts +1 -0
- package/src/zoom-controls/zoom-controls.stories.tsx +68 -0
- package/src/zoom-controls/zoom-controls.test.tsx +80 -0
- package/src/zoom-controls/zoom-controls.tsx +78 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { Edge, Node } from "@xyflow/react";
|
|
3
|
+
import { layoutGraph, type LayoutOptions } from "./layout-graph";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Memoized `layoutGraph` — pure graph-geometry layout (no live canvas access,
|
|
7
|
+
* no side effects). Recomputes only when `nodes`, `edges`, or `options`
|
|
8
|
+
* change; pass stable references (e.g. from `useNodesState`/`useEdgesState`
|
|
9
|
+
* and a memoized `options` object) to avoid recomputing every render.
|
|
10
|
+
*
|
|
11
|
+
* Use this when you want laid-out nodes to render with (e.g. feeding
|
|
12
|
+
* `useNodesState`'s initial value, or a derived/read-only view). To apply a
|
|
13
|
+
* layout to a *live* React Flow instance (read current nodes, set them back,
|
|
14
|
+
* fit the view), call `layoutGraph` directly from an event handler instead —
|
|
15
|
+
* see the `LayoutGraph` story.
|
|
16
|
+
*/
|
|
17
|
+
export function useAutoLayout<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
|
|
18
|
+
nodes: NodeType[],
|
|
19
|
+
edges: EdgeType[],
|
|
20
|
+
options: LayoutOptions,
|
|
21
|
+
): NodeType[] {
|
|
22
|
+
return useMemo(
|
|
23
|
+
() => layoutGraph<NodeType, EdgeType>(nodes, edges, options),
|
|
24
|
+
[nodes, edges, options],
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { useNodesInitialized, useReactFlow, type Edge, type Node } from "@xyflow/react";
|
|
3
|
+
import { layoutFlow, type FlowLayoutDirection, type FlowLayoutOptions } from "./flow-layout";
|
|
4
|
+
|
|
5
|
+
export interface UseFlowLayoutResult {
|
|
6
|
+
/**
|
|
7
|
+
* Auto-lay-out the current nodes/edges with dagre and fit the view. Safe to
|
|
8
|
+
* call repeatedly (e.g. from a toolbar button on every click). No-ops until
|
|
9
|
+
* React Flow has measured every node.
|
|
10
|
+
*/
|
|
11
|
+
layout: (direction?: FlowLayoutDirection, options?: Omit<FlowLayoutOptions, "direction">) => void;
|
|
12
|
+
/** Whether nodes are measured and ready to be laid out. */
|
|
13
|
+
ready: boolean;
|
|
14
|
+
/** Whether a layout pass is currently running (true only for the duration of `layout()`). */
|
|
15
|
+
layouting: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hook form of `layoutFlow`. Reads the live nodes/edges from `useReactFlow()`,
|
|
20
|
+
* waits for `useNodesInitialized()` so real (measured) node sizes are used —
|
|
21
|
+
* never lays out against 0×0 unmeasured nodes — applies the computed
|
|
22
|
+
* positions via `setNodes`, then calls `fitView()`.
|
|
23
|
+
*
|
|
24
|
+
* Must be called from a component rendered inside a `<CanvasShell>` /
|
|
25
|
+
* `<ReactFlow>` (React Flow context).
|
|
26
|
+
*/
|
|
27
|
+
export function useFlowLayout<
|
|
28
|
+
NodeType extends Node = Node,
|
|
29
|
+
EdgeType extends Edge = Edge,
|
|
30
|
+
>(): UseFlowLayoutResult {
|
|
31
|
+
const { getNodes, getEdges, setNodes, fitView } = useReactFlow<NodeType, EdgeType>();
|
|
32
|
+
const nodesInitialized = useNodesInitialized();
|
|
33
|
+
const [layouting, setLayouting] = useState(false);
|
|
34
|
+
// Avoid overlapping layout passes if `layout()` is invoked in quick succession.
|
|
35
|
+
const runningRef = useRef(false);
|
|
36
|
+
|
|
37
|
+
const layout = useCallback(
|
|
38
|
+
(direction?: FlowLayoutDirection, options?: Omit<FlowLayoutOptions, "direction">) => {
|
|
39
|
+
if (!nodesInitialized || runningRef.current) return;
|
|
40
|
+
|
|
41
|
+
runningRef.current = true;
|
|
42
|
+
setLayouting(true);
|
|
43
|
+
try {
|
|
44
|
+
const { nodes: layoutedNodes } = layoutFlow<NodeType, EdgeType>(getNodes(), getEdges(), {
|
|
45
|
+
...options,
|
|
46
|
+
direction,
|
|
47
|
+
});
|
|
48
|
+
setNodes(layoutedNodes);
|
|
49
|
+
// Defer fitView one frame so React Flow has committed the new positions first.
|
|
50
|
+
requestAnimationFrame(() => fitView());
|
|
51
|
+
} finally {
|
|
52
|
+
runningRef.current = false;
|
|
53
|
+
setLayouting(false);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
[nodesInitialized, getNodes, getEdges, setNodes, fitView],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return { layout, ready: nodesInitialized, layouting };
|
|
60
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import "@xyflow/react/dist/style.css";
|
|
3
|
+
import { CanvasShell } from "../canvas-shell";
|
|
4
|
+
import { FlowNode, FLOW_ALL_SIDE_HANDLES, type BrandFlowNode } from "../flow-node";
|
|
5
|
+
import { FlowSmartEdge } from "../flow-smart-edge";
|
|
6
|
+
import { ZoomControls } from "../zoom-controls";
|
|
7
|
+
import { FlowMiniMap } from "./flow-mini-map";
|
|
8
|
+
import type { Edge } from "@xyflow/react";
|
|
9
|
+
|
|
10
|
+
const nodeTypes = { brand: FlowNode };
|
|
11
|
+
const edgeTypes = { smart: FlowSmartEdge };
|
|
12
|
+
|
|
13
|
+
// Anchors on all four sides + the smart edge → each connection lands on the side
|
|
14
|
+
// that faces the other node (here the graph runs left→right, so edges attach on
|
|
15
|
+
// the right/left sides), not always top/bottom.
|
|
16
|
+
const nodes: BrandFlowNode[] = [
|
|
17
|
+
{
|
|
18
|
+
id: "1",
|
|
19
|
+
type: "brand",
|
|
20
|
+
position: { x: 0, y: 0 },
|
|
21
|
+
data: {
|
|
22
|
+
kind: "Source",
|
|
23
|
+
title: "Postgres",
|
|
24
|
+
subtitle: "orders",
|
|
25
|
+
tone: "accent",
|
|
26
|
+
handles: FLOW_ALL_SIDE_HANDLES,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "2",
|
|
31
|
+
type: "brand",
|
|
32
|
+
position: { x: 250, y: 140 },
|
|
33
|
+
data: {
|
|
34
|
+
kind: "Transform",
|
|
35
|
+
title: "Clean & join",
|
|
36
|
+
tone: "default",
|
|
37
|
+
handles: FLOW_ALL_SIDE_HANDLES,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "3",
|
|
42
|
+
type: "brand",
|
|
43
|
+
position: { x: 500, y: 0 },
|
|
44
|
+
data: { kind: "Output", title: "Dashboard", tone: "success", handles: FLOW_ALL_SIDE_HANDLES },
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
const edges: Edge[] = [
|
|
48
|
+
{ id: "e1-2", source: "1", target: "2", type: "smart" },
|
|
49
|
+
{ id: "e2-3", source: "2", target: "3", type: "smart" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const meta = {
|
|
53
|
+
title: "Flow/FlowMiniMap",
|
|
54
|
+
component: FlowMiniMap,
|
|
55
|
+
tags: ["autodocs"],
|
|
56
|
+
parameters: { layout: "fullscreen" },
|
|
57
|
+
} satisfies Meta<typeof FlowMiniMap>;
|
|
58
|
+
export default meta;
|
|
59
|
+
type Story = StoryObj<typeof meta>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* FlowMiniMap must render inside a CanvasShell (React Flow context). Node dots
|
|
63
|
+
* and the viewport mask are token-driven, so they stay legible in every theme.
|
|
64
|
+
*/
|
|
65
|
+
export const Default: Story = {
|
|
66
|
+
render: () => (
|
|
67
|
+
<div className="h-[500px]">
|
|
68
|
+
<CanvasShell nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}>
|
|
69
|
+
<ZoomControls />
|
|
70
|
+
<FlowMiniMap />
|
|
71
|
+
</CanvasShell>
|
|
72
|
+
</div>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { cleanup, render } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// @xyflow/react's MiniMap needs real flow context/measurement — mock it and
|
|
5
|
+
// assert the brand wrapper's own output (token-driven color defaults +
|
|
6
|
+
// className merge). Real rendering + a11y are covered by Storybook
|
|
7
|
+
// interaction tests.
|
|
8
|
+
const miniMapSpy = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("@xyflow/react", () => {
|
|
11
|
+
// 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
|
|
12
|
+
const React = require("react");
|
|
13
|
+
return {
|
|
14
|
+
MiniMap: (props: { className?: string }) => {
|
|
15
|
+
miniMapSpy(props);
|
|
16
|
+
return React.createElement("div", {
|
|
17
|
+
"data-testid": "rf-minimap",
|
|
18
|
+
className: props.className,
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
import { FlowMiniMap } from "./flow-mini-map";
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
cleanup();
|
|
28
|
+
miniMapSpy.mockClear();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("FlowMiniMap", () => {
|
|
32
|
+
it("renders without throwing", () => {
|
|
33
|
+
const { getByTestId } = render(<FlowMiniMap />);
|
|
34
|
+
expect(getByTestId("rf-minimap")).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("defaults nodeColor/nodeStrokeColor/maskColor to the flow-minimap tokens", () => {
|
|
38
|
+
render(<FlowMiniMap />);
|
|
39
|
+
const props = miniMapSpy.mock.calls[0]?.[0];
|
|
40
|
+
expect(props.nodeColor).toBe("var(--flow-minimap-node)");
|
|
41
|
+
expect(props.nodeStrokeColor).toBe("var(--flow-minimap-node)");
|
|
42
|
+
expect(props.maskColor).toBe("var(--flow-minimap-mask)");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("lets a caller override nodeColor/maskColor", () => {
|
|
46
|
+
render(<FlowMiniMap nodeColor="red" maskColor="blue" />);
|
|
47
|
+
const props = miniMapSpy.mock.calls[0]?.[0];
|
|
48
|
+
expect(props.nodeColor).toBe("red");
|
|
49
|
+
expect(props.maskColor).toBe("blue");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("merges a custom className with the branded panel classes", () => {
|
|
53
|
+
const { getByTestId } = render(<FlowMiniMap className="my-minimap" />);
|
|
54
|
+
expect(getByTestId("rf-minimap")).toHaveClass("my-minimap");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MiniMap, type MiniMapProps, type Node } from "@xyflow/react";
|
|
2
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
3
|
+
|
|
4
|
+
export type FlowMiniMapProps<NodeType extends Node = Node> = MiniMapProps<NodeType>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Branded preset over React Flow's `<MiniMap>` — sources node/mask colors from
|
|
8
|
+
* the `--flow-minimap-*` tokens so the overview stays theme-aware (the raw
|
|
9
|
+
* `MiniMap` re-export defaults to library colors and renders theme-blind,
|
|
10
|
+
* especially under a low-chroma theme). Every prop can still be overridden; render
|
|
11
|
+
* inside a `<CanvasShell>` (it needs the React Flow context via `useStore`).
|
|
12
|
+
*/
|
|
13
|
+
export function FlowMiniMap<NodeType extends Node = Node>({
|
|
14
|
+
className,
|
|
15
|
+
nodeColor = "var(--flow-minimap-node)",
|
|
16
|
+
nodeStrokeColor = "var(--flow-minimap-node)",
|
|
17
|
+
maskColor = "var(--flow-minimap-mask)",
|
|
18
|
+
...props
|
|
19
|
+
}: FlowMiniMapProps<NodeType>) {
|
|
20
|
+
return (
|
|
21
|
+
<MiniMap
|
|
22
|
+
className={cn("!rounded-lg !bg-surface-elevated !shadow-ring-sm", className)}
|
|
23
|
+
nodeColor={nodeColor}
|
|
24
|
+
nodeStrokeColor={nodeStrokeColor}
|
|
25
|
+
maskColor={maskColor}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FlowMiniMap, type FlowMiniMapProps } from "./flow-mini-map";
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import "@xyflow/react/dist/style.css";
|
|
3
|
+
import { type Edge } from "@xyflow/react";
|
|
4
|
+
import { CanvasShell } from "../canvas-shell";
|
|
5
|
+
import { FlowNode, type BrandFlowNode } from "./flow-node";
|
|
6
|
+
|
|
7
|
+
const nodeTypes = { brand: FlowNode };
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: "Flow/FlowNode",
|
|
11
|
+
component: FlowNode,
|
|
12
|
+
tags: ["autodocs"],
|
|
13
|
+
parameters: { layout: "fullscreen" },
|
|
14
|
+
} satisfies Meta<typeof FlowNode>;
|
|
15
|
+
export default meta;
|
|
16
|
+
type Story = StoryObj<typeof meta>;
|
|
17
|
+
|
|
18
|
+
/** Default node with all data fields populated (kind, title, subtitle, tone=accent). */
|
|
19
|
+
export const Default: Story = {
|
|
20
|
+
render: () => {
|
|
21
|
+
const nodes: BrandFlowNode[] = [
|
|
22
|
+
{
|
|
23
|
+
id: "1",
|
|
24
|
+
type: "brand",
|
|
25
|
+
position: { x: 120, y: 80 },
|
|
26
|
+
data: { kind: "Source", title: "Postgres", subtitle: "orders table", tone: "accent" },
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
return (
|
|
30
|
+
<div className="h-[300px]">
|
|
31
|
+
<CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes} />
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Tone variants: default, accent, success, warning, destructive. */
|
|
38
|
+
export const Tones: Story = {
|
|
39
|
+
render: () => {
|
|
40
|
+
const nodes: BrandFlowNode[] = [
|
|
41
|
+
{
|
|
42
|
+
id: "default",
|
|
43
|
+
type: "brand",
|
|
44
|
+
position: { x: 20, y: 20 },
|
|
45
|
+
data: { kind: "Transform", title: "Clean & join", tone: "default" },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "accent",
|
|
49
|
+
type: "brand",
|
|
50
|
+
position: { x: 220, y: 20 },
|
|
51
|
+
data: { kind: "Source", title: "Postgres", tone: "accent" },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "success",
|
|
55
|
+
type: "brand",
|
|
56
|
+
position: { x: 420, y: 20 },
|
|
57
|
+
data: { kind: "Output", title: "Dashboard", tone: "success" },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "warning",
|
|
61
|
+
type: "brand",
|
|
62
|
+
position: { x: 620, y: 20 },
|
|
63
|
+
data: { kind: "Alert", title: "Latency", tone: "warning" },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "destructive",
|
|
67
|
+
type: "brand",
|
|
68
|
+
position: { x: 820, y: 20 },
|
|
69
|
+
data: { kind: "Error", title: "Failed", tone: "destructive" },
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
return (
|
|
73
|
+
<div className="h-[200px]">
|
|
74
|
+
<CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes} />
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Node without optional subtitle or kind — minimal data shape. */
|
|
81
|
+
export const Minimal: Story = {
|
|
82
|
+
render: () => {
|
|
83
|
+
const nodes: BrandFlowNode[] = [
|
|
84
|
+
{
|
|
85
|
+
id: "1",
|
|
86
|
+
type: "brand",
|
|
87
|
+
position: { x: 120, y: 80 },
|
|
88
|
+
data: { title: "Simple node" },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
return (
|
|
92
|
+
<div className="h-[200px]">
|
|
93
|
+
<CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes} />
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Selected node shows a ring highlight. */
|
|
100
|
+
export const Selected: Story = {
|
|
101
|
+
render: () => {
|
|
102
|
+
const nodes: BrandFlowNode[] = [
|
|
103
|
+
{
|
|
104
|
+
id: "1",
|
|
105
|
+
type: "brand",
|
|
106
|
+
position: { x: 120, y: 80 },
|
|
107
|
+
selected: true,
|
|
108
|
+
data: { kind: "Output", title: "Dashboard", subtitle: "analytics", tone: "success" },
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
return (
|
|
112
|
+
<div className="h-[250px]">
|
|
113
|
+
<CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes} />
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Two connected nodes to show handles in context. */
|
|
120
|
+
export const Connected: Story = {
|
|
121
|
+
render: () => {
|
|
122
|
+
const nodes: BrandFlowNode[] = [
|
|
123
|
+
{
|
|
124
|
+
id: "1",
|
|
125
|
+
type: "brand",
|
|
126
|
+
position: { x: 80, y: 40 },
|
|
127
|
+
data: { kind: "Source", title: "Postgres", tone: "accent" },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "2",
|
|
131
|
+
type: "brand",
|
|
132
|
+
position: { x: 80, y: 200 },
|
|
133
|
+
data: { kind: "Output", title: "Dashboard", tone: "success" },
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
const edges: Edge[] = [{ id: "e1-2", source: "1", target: "2" }];
|
|
137
|
+
return (
|
|
138
|
+
<div className="h-[350px]">
|
|
139
|
+
<CanvasShell nodes={nodes} edges={edges} nodeTypes={nodeTypes} />
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// @xyflow/react requires real layout/measurement — mock the engine and assert
|
|
5
|
+
// the brand component's own output. Real rendering + a11y are covered by
|
|
6
|
+
// Storybook interaction tests.
|
|
7
|
+
vi.mock("@xyflow/react", () => {
|
|
8
|
+
// 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
|
|
9
|
+
const React = require("react");
|
|
10
|
+
return {
|
|
11
|
+
Handle: ({
|
|
12
|
+
type,
|
|
13
|
+
position,
|
|
14
|
+
className,
|
|
15
|
+
}: {
|
|
16
|
+
type: string;
|
|
17
|
+
position: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}) =>
|
|
20
|
+
React.createElement("div", {
|
|
21
|
+
"data-testid": `handle-${type}`,
|
|
22
|
+
"data-position": position,
|
|
23
|
+
className,
|
|
24
|
+
}),
|
|
25
|
+
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
import { FlowNode, type BrandFlowNode } from "./flow-node";
|
|
30
|
+
import type { NodeProps } from "@xyflow/react";
|
|
31
|
+
|
|
32
|
+
afterEach(cleanup);
|
|
33
|
+
|
|
34
|
+
/** Minimal NodeProps factory for FlowNode (BrandFlowNode). */
|
|
35
|
+
function makeProps(
|
|
36
|
+
data: BrandFlowNode["data"],
|
|
37
|
+
overrides: Partial<NodeProps<BrandFlowNode>> = {},
|
|
38
|
+
): NodeProps<BrandFlowNode> {
|
|
39
|
+
return {
|
|
40
|
+
id: "test-node",
|
|
41
|
+
data,
|
|
42
|
+
selected: false,
|
|
43
|
+
dragging: false,
|
|
44
|
+
zIndex: 0,
|
|
45
|
+
isConnectable: true,
|
|
46
|
+
draggable: true,
|
|
47
|
+
deletable: true,
|
|
48
|
+
selectable: true,
|
|
49
|
+
positionAbsoluteX: 0,
|
|
50
|
+
positionAbsoluteY: 0,
|
|
51
|
+
width: 160,
|
|
52
|
+
height: 48,
|
|
53
|
+
type: "brand",
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("FlowNode", () => {
|
|
59
|
+
it("renders the title", () => {
|
|
60
|
+
render(<FlowNode {...makeProps({ title: "My Node" })} />);
|
|
61
|
+
expect(screen.getByText("My Node")).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders the subtitle when provided", () => {
|
|
65
|
+
render(<FlowNode {...makeProps({ title: "Node", subtitle: "sub detail" })} />);
|
|
66
|
+
expect(screen.getByText("sub detail")).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders the kind eyebrow when provided", () => {
|
|
70
|
+
render(<FlowNode {...makeProps({ title: "Transform", kind: "Source" })} />);
|
|
71
|
+
expect(screen.getByText("Source")).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("renders source and target handles", () => {
|
|
75
|
+
render(<FlowNode {...makeProps({ title: "Node" })} />);
|
|
76
|
+
expect(screen.getByTestId("handle-target")).toBeInTheDocument();
|
|
77
|
+
expect(screen.getByTestId("handle-source")).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("renders an icon when provided", () => {
|
|
81
|
+
render(
|
|
82
|
+
<FlowNode
|
|
83
|
+
{...makeProps({ title: "Node", icon: <svg data-testid="node-icon" aria-hidden="true" /> })}
|
|
84
|
+
/>,
|
|
85
|
+
);
|
|
86
|
+
expect(screen.getByTestId("node-icon")).toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("omits the subtitle when not provided", () => {
|
|
90
|
+
render(<FlowNode {...makeProps({ title: "Node" })} />);
|
|
91
|
+
// Only the title text should be present
|
|
92
|
+
expect(screen.queryByText("sub detail")).not.toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// #387 — `tone` used to be encoded in colour ALONE (a 1px border, no DOM
|
|
97
|
+
// attribute, no icon, no accessible name — WCAG 1.4.1). Every non-default
|
|
98
|
+
// tone now carries a `data-tone` attribute, a distinct-SHAPE Lucide glyph
|
|
99
|
+
// (not just a distinct class string — asserted via each icon's own
|
|
100
|
+
// `lucide-<name>` class, which is the actual rendered shape signature) and a
|
|
101
|
+
// distinct `sr-only` accessible name. `default` deliberately gets none of
|
|
102
|
+
// the three, so an ordinary node stays visually and AT-quiet.
|
|
103
|
+
describe("FlowNode tone — colour is never the only channel (#387)", () => {
|
|
104
|
+
const TONE_GLYPH_CLASS: Record<string, string | null> = {
|
|
105
|
+
default: null,
|
|
106
|
+
accent: "lucide-star",
|
|
107
|
+
success: "lucide-circle-check",
|
|
108
|
+
warning: "lucide-clock",
|
|
109
|
+
destructive: "lucide-circle-alert",
|
|
110
|
+
};
|
|
111
|
+
const TONE_LABEL: Record<string, string | null> = {
|
|
112
|
+
default: null,
|
|
113
|
+
accent: "Highlighted",
|
|
114
|
+
success: "Success",
|
|
115
|
+
warning: "Warning",
|
|
116
|
+
destructive: "Destructive",
|
|
117
|
+
};
|
|
118
|
+
const ALL_GLYPH_CLASSES = Object.values(TONE_GLYPH_CLASS).filter((c): c is string => c !== null);
|
|
119
|
+
|
|
120
|
+
it("every tone's glyph is a distinct SHAPE — no two tones share an icon", () => {
|
|
121
|
+
expect(new Set(ALL_GLYPH_CLASSES).size).toBe(ALL_GLYPH_CLASSES.length);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.each(Object.keys(TONE_GLYPH_CLASS))(
|
|
125
|
+
"tone=%s exposes data-tone and its own non-colour glyph + accessible name",
|
|
126
|
+
(tone) => {
|
|
127
|
+
const { container } = render(
|
|
128
|
+
<FlowNode {...makeProps({ title: "Node", tone: tone as BrandFlowNode["data"]["tone"] })} />,
|
|
129
|
+
);
|
|
130
|
+
expect(container.querySelector(`[data-tone="${tone}"]`)).toBeInTheDocument();
|
|
131
|
+
|
|
132
|
+
const expectedGlyph = TONE_GLYPH_CLASS[tone];
|
|
133
|
+
for (const glyphClass of ALL_GLYPH_CLASSES) {
|
|
134
|
+
const present = container.querySelector(`svg.${glyphClass}`) !== null;
|
|
135
|
+
expect(present).toBe(glyphClass === expectedGlyph);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const expectedLabel = TONE_LABEL[tone];
|
|
139
|
+
if (expectedLabel) {
|
|
140
|
+
expect(container).toHaveTextContent(expectedLabel);
|
|
141
|
+
} else {
|
|
142
|
+
for (const label of Object.values(TONE_LABEL)) {
|
|
143
|
+
if (label) expect(container).not.toHaveTextContent(label);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
it("the tone glyph is aria-hidden (the sr-only text carries the meaning, not the icon)", () => {
|
|
150
|
+
const { container } = render(
|
|
151
|
+
<FlowNode {...makeProps({ title: "Node", tone: "destructive" })} />,
|
|
152
|
+
);
|
|
153
|
+
const glyph = container.querySelector("svg.lucide-circle-alert");
|
|
154
|
+
expect(glyph).toHaveAttribute("aria-hidden", "true");
|
|
155
|
+
});
|
|
156
|
+
});
|