@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,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import { useReactFlow, type Node, type NodeChange } from "@xyflow/react";
|
|
5
|
+
import { getHelperLines, type HelperLineRect } from "./get-helper-lines";
|
|
6
|
+
|
|
7
|
+
export interface UseHelperLinesOptions {
|
|
8
|
+
/** Alignment threshold in flow coordinates. Defaults to 5. */
|
|
9
|
+
threshold?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseHelperLinesResult<NodeType extends Node = Node> {
|
|
13
|
+
/**
|
|
14
|
+
* Drop-in replacement for the consumer's `onNodesChange`: it snaps a dragged
|
|
15
|
+
* node onto any near-aligned edge/center, then forwards the (mutated) changes
|
|
16
|
+
* to the wrapped handler.
|
|
17
|
+
*/
|
|
18
|
+
onNodesChange: (changes: NodeChange<NodeType>[]) => void;
|
|
19
|
+
/** Absolute flow-y of the active horizontal guide, or `undefined`. */
|
|
20
|
+
helperLineHorizontal: number | undefined;
|
|
21
|
+
/** Absolute flow-x of the active vertical guide, or `undefined`. */
|
|
22
|
+
helperLineVertical: number | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function nodeRect(node: Node): HelperLineRect {
|
|
26
|
+
return {
|
|
27
|
+
x: node.position.x,
|
|
28
|
+
y: node.position.y,
|
|
29
|
+
width: node.measured?.width ?? node.width ?? 0,
|
|
30
|
+
height: node.measured?.height ?? node.height ?? 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decorate a consumer's `onNodesChange` with alignment guides + snapping.
|
|
36
|
+
*
|
|
37
|
+
* While a **single** node is dragged, the returned handler compares its
|
|
38
|
+
* left/center/right and top/middle/bottom against every other node; when a pair
|
|
39
|
+
* is within `threshold` flow px it (a) snaps the dragged node's position change
|
|
40
|
+
* onto the guide and (b) exposes the guide coordinates so `<HelperLines>` can
|
|
41
|
+
* draw them. Guides clear when the drag ends or nothing aligns.
|
|
42
|
+
*
|
|
43
|
+
* Must be called inside a React Flow context (a `<ReactFlowProvider>` or a
|
|
44
|
+
* component rendered under `<ReactFlow>`), since it reads the live node store.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const [nodes, , onNodesChange] = useNodesState(initialNodes);
|
|
48
|
+
* const { onNodesChange: handle, helperLineHorizontal, helperLineVertical } =
|
|
49
|
+
* useHelperLines(onNodesChange);
|
|
50
|
+
* // <ReactFlow onNodesChange={handle}>
|
|
51
|
+
* // <HelperLines horizontal={helperLineHorizontal} vertical={helperLineVertical} />
|
|
52
|
+
*/
|
|
53
|
+
export function useHelperLines<NodeType extends Node = Node>(
|
|
54
|
+
onNodesChange?: (changes: NodeChange<NodeType>[]) => void,
|
|
55
|
+
options: UseHelperLinesOptions = {},
|
|
56
|
+
): UseHelperLinesResult<NodeType> {
|
|
57
|
+
const { threshold = 5 } = options;
|
|
58
|
+
const { getNodes } = useReactFlow<NodeType>();
|
|
59
|
+
const [helperLineHorizontal, setHelperLineHorizontal] = useState<number>();
|
|
60
|
+
const [helperLineVertical, setHelperLineVertical] = useState<number>();
|
|
61
|
+
|
|
62
|
+
const handleNodesChange = useCallback(
|
|
63
|
+
(changes: NodeChange<NodeType>[]) => {
|
|
64
|
+
let horizontal: number | undefined;
|
|
65
|
+
let vertical: number | undefined;
|
|
66
|
+
|
|
67
|
+
const dragChanges = changes.filter(
|
|
68
|
+
(c): c is Extract<NodeChange<NodeType>, { type: "position" }> =>
|
|
69
|
+
c.type === "position" && c.dragging === true && c.position != null,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Only guide/snap a single dragged node — multi-select drags are ambiguous.
|
|
73
|
+
if (dragChanges.length === 1) {
|
|
74
|
+
const change = dragChanges[0];
|
|
75
|
+
const nodes = getNodes();
|
|
76
|
+
const dragged = change ? nodes.find((n) => n.id === change.id) : undefined;
|
|
77
|
+
|
|
78
|
+
if (change && change.position && dragged) {
|
|
79
|
+
const draggedRect: HelperLineRect = {
|
|
80
|
+
x: change.position.x,
|
|
81
|
+
y: change.position.y,
|
|
82
|
+
width: dragged.measured?.width ?? dragged.width ?? 0,
|
|
83
|
+
height: dragged.measured?.height ?? dragged.height ?? 0,
|
|
84
|
+
};
|
|
85
|
+
const others = nodes.filter((n) => n.id !== change.id).map(nodeRect);
|
|
86
|
+
const {
|
|
87
|
+
snapX,
|
|
88
|
+
snapY,
|
|
89
|
+
vertical: v,
|
|
90
|
+
horizontal: h,
|
|
91
|
+
} = getHelperLines(draggedRect, others, threshold);
|
|
92
|
+
|
|
93
|
+
// Mutate the position change so the node lands exactly on the guide.
|
|
94
|
+
if (snapX !== undefined) change.position.x = snapX;
|
|
95
|
+
if (snapY !== undefined) change.position.y = snapY;
|
|
96
|
+
vertical = v;
|
|
97
|
+
horizontal = h;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setHelperLineHorizontal(horizontal);
|
|
102
|
+
setHelperLineVertical(vertical);
|
|
103
|
+
onNodesChange?.(changes);
|
|
104
|
+
},
|
|
105
|
+
[getNodes, onNodesChange, threshold],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return { onNodesChange: handleNodesChange, helperLineHorizontal, helperLineVertical };
|
|
109
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @elabs-ai/components-flow — branded React Flow (@xyflow/react) building blocks.
|
|
3
|
+
*
|
|
4
|
+
* Import the React Flow stylesheet once at the app root:
|
|
5
|
+
* import "@xyflow/react/dist/style.css";
|
|
6
|
+
*/
|
|
7
|
+
export * from "./canvas-shell";
|
|
8
|
+
export * from "./flow-node";
|
|
9
|
+
export * from "./flow-edge";
|
|
10
|
+
export * from "./flow-smart-edge";
|
|
11
|
+
export * from "./flow-floating-edge";
|
|
12
|
+
export * from "./flow-placeholder-node";
|
|
13
|
+
export * from "./flow-button-edge";
|
|
14
|
+
export * from "./flow-layout";
|
|
15
|
+
export * from "./helper-lines";
|
|
16
|
+
export * from "./flow-mini-map";
|
|
17
|
+
export * from "./inspector-panel";
|
|
18
|
+
export * from "./legend";
|
|
19
|
+
export * from "./zoom-controls";
|
|
20
|
+
export * from "./flow-group-node";
|
|
21
|
+
export * from "./use-flow-groups";
|
|
22
|
+
|
|
23
|
+
// Convenience re-exports so consumers can build flows without a direct dep.
|
|
24
|
+
export {
|
|
25
|
+
Background,
|
|
26
|
+
Controls,
|
|
27
|
+
MiniMap,
|
|
28
|
+
Panel,
|
|
29
|
+
Position,
|
|
30
|
+
ReactFlow,
|
|
31
|
+
ReactFlowProvider,
|
|
32
|
+
useReactFlow,
|
|
33
|
+
useNodesState,
|
|
34
|
+
useEdgesState,
|
|
35
|
+
addEdge,
|
|
36
|
+
} from "@xyflow/react";
|
|
37
|
+
export type { Node, Edge, Connection, NodeProps, EdgeProps } from "@xyflow/react";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { InspectorPanel, type InspectorPanelProps } from "./inspector-panel";
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { fn } from "storybook/test";
|
|
4
|
+
import { InspectorPanel } from "./inspector-panel";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Flow/InspectorPanel",
|
|
8
|
+
component: InspectorPanel,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
parameters: { layout: "centered" },
|
|
11
|
+
args: {
|
|
12
|
+
onClose: fn(),
|
|
13
|
+
},
|
|
14
|
+
} satisfies Meta<typeof InspectorPanel>;
|
|
15
|
+
export default meta;
|
|
16
|
+
type Story = StoryObj<typeof meta>;
|
|
17
|
+
|
|
18
|
+
/** Default: a node is selected; inspector shows its properties. */
|
|
19
|
+
export const Default: Story = {
|
|
20
|
+
args: {
|
|
21
|
+
title: "Inspector",
|
|
22
|
+
hasSelection: true,
|
|
23
|
+
selectionKey: "node-1",
|
|
24
|
+
children: (
|
|
25
|
+
<dl className="space-y-3">
|
|
26
|
+
<div>
|
|
27
|
+
<dt className="text-meta font-medium text-muted-foreground">Kind</dt>
|
|
28
|
+
<dd className="mt-0.5 text-body text-foreground">Source</dd>
|
|
29
|
+
</div>
|
|
30
|
+
<div>
|
|
31
|
+
<dt className="text-meta font-medium text-muted-foreground">Title</dt>
|
|
32
|
+
<dd className="mt-0.5 text-body text-foreground">Postgres</dd>
|
|
33
|
+
</div>
|
|
34
|
+
<div>
|
|
35
|
+
<dt className="text-meta font-medium text-muted-foreground">Subtitle</dt>
|
|
36
|
+
<dd className="mt-0.5 text-body text-foreground">orders table</dd>
|
|
37
|
+
</div>
|
|
38
|
+
<div>
|
|
39
|
+
<dt className="text-meta font-medium text-muted-foreground">Tone</dt>
|
|
40
|
+
<dd className="mt-0.5 text-body text-foreground">accent</dd>
|
|
41
|
+
</div>
|
|
42
|
+
</dl>
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
decorators: [
|
|
46
|
+
(Story) => (
|
|
47
|
+
<div className="h-[400px] w-72">
|
|
48
|
+
<Story />
|
|
49
|
+
</div>
|
|
50
|
+
),
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Empty state when nothing is selected. */
|
|
55
|
+
export const NoSelection: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
title: "Inspector",
|
|
58
|
+
hasSelection: false,
|
|
59
|
+
children: null,
|
|
60
|
+
},
|
|
61
|
+
decorators: [
|
|
62
|
+
(Story) => (
|
|
63
|
+
<div className="h-[400px] w-72">
|
|
64
|
+
<Story />
|
|
65
|
+
</div>
|
|
66
|
+
),
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Custom empty message. */
|
|
71
|
+
export const CustomEmptyMessage: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
title: "Node details",
|
|
74
|
+
hasSelection: false,
|
|
75
|
+
emptyMessage: "Click a node or edge to inspect its properties.",
|
|
76
|
+
children: null,
|
|
77
|
+
},
|
|
78
|
+
decorators: [
|
|
79
|
+
(Story) => (
|
|
80
|
+
<div className="h-[400px] w-72">
|
|
81
|
+
<Story />
|
|
82
|
+
</div>
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function CollapsibleDemo() {
|
|
88
|
+
const [open, setOpen] = useState(true);
|
|
89
|
+
return (
|
|
90
|
+
<div className="flex h-[400px] w-[480px] overflow-hidden rounded-lg border">
|
|
91
|
+
<div className="flex min-w-0 flex-1 flex-col items-start gap-2 p-4">
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
aria-expanded={open}
|
|
95
|
+
onClick={() => setOpen((o) => !o)}
|
|
96
|
+
className="rounded-md border border-input px-3 py-1.5 text-body hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
97
|
+
>
|
|
98
|
+
Toggle inspector
|
|
99
|
+
</button>
|
|
100
|
+
<p className="text-body text-muted-foreground">
|
|
101
|
+
The panel is always mounted — the width tweens via the shared useCollapsiblePanel base (no
|
|
102
|
+
conditional mount).
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
<InspectorPanel
|
|
106
|
+
title="Inspector"
|
|
107
|
+
hasSelection
|
|
108
|
+
selectionKey="node-1"
|
|
109
|
+
open={open}
|
|
110
|
+
onOpenChange={setOpen}
|
|
111
|
+
onClose={() => setOpen(false)}
|
|
112
|
+
>
|
|
113
|
+
<dl className="space-y-3">
|
|
114
|
+
<div>
|
|
115
|
+
<dt className="text-meta text-muted-foreground">Kind</dt>
|
|
116
|
+
<dd className="mt-0.5 text-body text-foreground">Source</dd>
|
|
117
|
+
</div>
|
|
118
|
+
<div>
|
|
119
|
+
<dt className="text-meta text-muted-foreground">Title</dt>
|
|
120
|
+
<dd className="mt-0.5 text-body text-foreground">Postgres</dd>
|
|
121
|
+
</div>
|
|
122
|
+
</dl>
|
|
123
|
+
</InspectorPanel>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Collapse (research 09 step 3): controlled open on the shared @elabs-ai/components-ui base. */
|
|
129
|
+
export const Collapsible: Story = {
|
|
130
|
+
args: { children: null },
|
|
131
|
+
render: () => <CollapsibleDemo />,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Without a close button (no onClose). */
|
|
135
|
+
export const NoClose: Story = {
|
|
136
|
+
args: {
|
|
137
|
+
title: "Properties",
|
|
138
|
+
hasSelection: true,
|
|
139
|
+
onClose: undefined,
|
|
140
|
+
selectionKey: "node-2",
|
|
141
|
+
children: (
|
|
142
|
+
<dl className="space-y-3">
|
|
143
|
+
<div>
|
|
144
|
+
<dt className="text-meta font-medium text-muted-foreground">Kind</dt>
|
|
145
|
+
<dd className="mt-0.5 text-body text-foreground">Transform</dd>
|
|
146
|
+
</div>
|
|
147
|
+
<div>
|
|
148
|
+
<dt className="text-meta font-medium text-muted-foreground">Title</dt>
|
|
149
|
+
<dd className="mt-0.5 text-body text-foreground">Clean & join</dd>
|
|
150
|
+
</div>
|
|
151
|
+
</dl>
|
|
152
|
+
),
|
|
153
|
+
},
|
|
154
|
+
decorators: [
|
|
155
|
+
(Story) => (
|
|
156
|
+
<div className="h-[400px] w-72">
|
|
157
|
+
<Story />
|
|
158
|
+
</div>
|
|
159
|
+
),
|
|
160
|
+
],
|
|
161
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { InspectorPanel } from "./inspector-panel";
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
describe("InspectorPanel", () => {
|
|
8
|
+
it("renders the default title", () => {
|
|
9
|
+
render(<InspectorPanel>Details here</InspectorPanel>);
|
|
10
|
+
expect(screen.getByRole("heading", { name: "Inspector" })).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders a custom title", () => {
|
|
14
|
+
render(<InspectorPanel title="Node Properties">Details</InspectorPanel>);
|
|
15
|
+
expect(screen.getByRole("heading", { name: "Node Properties" })).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders children when hasSelection is true (default)", () => {
|
|
19
|
+
render(<InspectorPanel>Panel content</InspectorPanel>);
|
|
20
|
+
expect(screen.getByText("Panel content")).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders the empty message when hasSelection is false", () => {
|
|
24
|
+
render(<InspectorPanel hasSelection={false}>Panel content</InspectorPanel>);
|
|
25
|
+
expect(screen.getByText("Select a node to see its details.")).toBeInTheDocument();
|
|
26
|
+
expect(screen.queryByText("Panel content")).not.toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders a custom empty message", () => {
|
|
30
|
+
render(
|
|
31
|
+
<InspectorPanel hasSelection={false} emptyMessage="No edge selected.">
|
|
32
|
+
Panel content
|
|
33
|
+
</InspectorPanel>,
|
|
34
|
+
);
|
|
35
|
+
expect(screen.getByText("No edge selected.")).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("renders the close button when onClose is provided", () => {
|
|
39
|
+
const onClose = vi.fn();
|
|
40
|
+
render(<InspectorPanel onClose={onClose}>Details</InspectorPanel>);
|
|
41
|
+
expect(screen.getByRole("button", { name: "Close inspector" })).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("omits the close button when onClose is not provided", () => {
|
|
45
|
+
render(<InspectorPanel>Details</InspectorPanel>);
|
|
46
|
+
expect(screen.queryByRole("button", { name: "Close inspector" })).not.toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("calls onClose when the close button is clicked", async () => {
|
|
50
|
+
const onClose = vi.fn();
|
|
51
|
+
const { getByRole } = render(<InspectorPanel onClose={onClose}>Details</InspectorPanel>);
|
|
52
|
+
getByRole("button", { name: "Close inspector" }).click();
|
|
53
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("applies custom className to the root element", () => {
|
|
57
|
+
const { container } = render(<InspectorPanel className="my-panel">Details</InspectorPanel>);
|
|
58
|
+
expect(container.firstChild).toHaveClass("my-panel");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("converges on the shared collapse base: expanded by default, always mounted", () => {
|
|
62
|
+
const { container } = render(<InspectorPanel>Details</InspectorPanel>);
|
|
63
|
+
const root = container.firstChild as HTMLElement;
|
|
64
|
+
expect(root).toHaveAttribute("data-state", "expanded");
|
|
65
|
+
expect(root).toHaveAttribute("data-side", "right");
|
|
66
|
+
expect(root).not.toHaveAttribute("inert");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("collapses via controlled open without unmounting (research 09 step 3)", () => {
|
|
70
|
+
const { container, rerender } = render(<InspectorPanel open>Details</InspectorPanel>);
|
|
71
|
+
const root = container.firstChild as HTMLElement;
|
|
72
|
+
expect(root).toHaveAttribute("data-state", "expanded");
|
|
73
|
+
|
|
74
|
+
rerender(<InspectorPanel open={false}>Details</InspectorPanel>);
|
|
75
|
+
expect(container.firstChild).toBe(root);
|
|
76
|
+
expect(root).toHaveAttribute("data-state", "collapsed");
|
|
77
|
+
// Collapsed panel is inert so its controls leave the tab order.
|
|
78
|
+
expect(root).toHaveAttribute("inert");
|
|
79
|
+
// Content stays mounted (the width tween needs a mounted node).
|
|
80
|
+
expect(screen.getByText("Details")).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { Reveal, useCollapsiblePanel } from "@elabs-ai/components-ui";
|
|
3
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
4
|
+
|
|
5
|
+
const INSPECTOR_PANEL_WIDTH = "18rem";
|
|
6
|
+
|
|
7
|
+
export interface InspectorPanelProps {
|
|
8
|
+
title?: ReactNode;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
onClose?: () => void;
|
|
11
|
+
/** Empty-state shown when nothing is selected. */
|
|
12
|
+
emptyMessage?: ReactNode;
|
|
13
|
+
/** When false, render the empty state instead of children. */
|
|
14
|
+
hasSelection?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Stable identity of the current selection (e.g. the selected node's id).
|
|
17
|
+
* When it changes, the body replays a quick fade so it reads as "details for
|
|
18
|
+
* THIS node". Pass the selected entity's id (or any stable key derived from
|
|
19
|
+
* the selected data).
|
|
20
|
+
*/
|
|
21
|
+
selectionKey?: string | number;
|
|
22
|
+
/**
|
|
23
|
+
* Collapse (research 09 step 3 — converged on the shared `@elabs-ai/components-ui`
|
|
24
|
+
* `useCollapsiblePanel` base; the panel stays single-view, drill-in is
|
|
25
|
+
* ContextPanel-only). Controlled `open` / uncontrolled `defaultOpen`.
|
|
26
|
+
*/
|
|
27
|
+
open?: boolean;
|
|
28
|
+
defaultOpen?: boolean;
|
|
29
|
+
onOpenChange?: (open: boolean) => void;
|
|
30
|
+
/** Which edge the panel sits on (collapse slide direction). Default `"right"`. */
|
|
31
|
+
side?: "left" | "right";
|
|
32
|
+
/** Expanded width (any CSS length). Default `"18rem"` (the original `w-72`). */
|
|
33
|
+
width?: string;
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Properties panel for the selected node/edge. Reusable beside a canvas (e.g.
|
|
39
|
+
* inside a <SplitPanel>) or as a React Flow <Panel>. Always mounted; collapses
|
|
40
|
+
* via the canonical `useCollapsiblePanel` width tween (CSS-gated, reduced-
|
|
41
|
+
* motion safe) when `open`/`defaultOpen` drive it.
|
|
42
|
+
*/
|
|
43
|
+
export function InspectorPanel({
|
|
44
|
+
title = "Inspector",
|
|
45
|
+
children,
|
|
46
|
+
onClose,
|
|
47
|
+
emptyMessage = "Select a node to see its details.",
|
|
48
|
+
hasSelection = true,
|
|
49
|
+
selectionKey,
|
|
50
|
+
open,
|
|
51
|
+
defaultOpen = true,
|
|
52
|
+
onOpenChange,
|
|
53
|
+
side = "right",
|
|
54
|
+
width = INSPECTOR_PANEL_WIDTH,
|
|
55
|
+
className,
|
|
56
|
+
}: InspectorPanelProps) {
|
|
57
|
+
const panel = useCollapsiblePanel({
|
|
58
|
+
side,
|
|
59
|
+
open,
|
|
60
|
+
defaultOpen,
|
|
61
|
+
onOpenChange,
|
|
62
|
+
widthClassName: "w-(--inspector-panel-width)",
|
|
63
|
+
spacerCollapsedClassName: "group-data-[state=collapsed]:w-0",
|
|
64
|
+
containerSlideClassNames: {
|
|
65
|
+
left: "left-0 group-data-[state=collapsed]:left-[calc(var(--inspector-panel-width)*-1)]",
|
|
66
|
+
right: "right-0 group-data-[state=collapsed]:right-[calc(var(--inspector-panel-width)*-1)]",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={cn("group relative h-full overflow-hidden", className)}
|
|
73
|
+
style={{ "--inspector-panel-width": width } as CSSProperties}
|
|
74
|
+
inert={panel.open ? undefined : true}
|
|
75
|
+
{...panel.attrs}
|
|
76
|
+
>
|
|
77
|
+
<div data-slot="inspector-panel-gap" className={panel.spacerClassName} />
|
|
78
|
+
<div
|
|
79
|
+
data-slot="inspector-panel-container"
|
|
80
|
+
// Bounded to the host (a SplitPanel / canvas edge), not the viewport:
|
|
81
|
+
// absolute within the relative group, full height (research 09 §B.2).
|
|
82
|
+
className={cn(panel.containerClassName, "absolute inset-y-0 flex h-full border-s")}
|
|
83
|
+
>
|
|
84
|
+
{/* Detail-tier surface: raised `card` — robust across themes (#193). */}
|
|
85
|
+
<div className="flex h-full w-full flex-col bg-card">
|
|
86
|
+
<div className="flex items-center justify-between border-b px-4 py-3">
|
|
87
|
+
<h3 className="text-body font-semibold text-foreground">{title}</h3>
|
|
88
|
+
{onClose ? (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
aria-label="Close inspector"
|
|
92
|
+
onClick={onClose}
|
|
93
|
+
className="rounded-sm p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
94
|
+
>
|
|
95
|
+
<svg
|
|
96
|
+
width="14"
|
|
97
|
+
height="14"
|
|
98
|
+
viewBox="0 0 24 24"
|
|
99
|
+
fill="none"
|
|
100
|
+
stroke="currentColor"
|
|
101
|
+
strokeWidth="2"
|
|
102
|
+
strokeLinecap="round"
|
|
103
|
+
strokeLinejoin="round"
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
>
|
|
106
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
107
|
+
</svg>
|
|
108
|
+
</button>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
111
|
+
{hasSelection ? (
|
|
112
|
+
<Reveal
|
|
113
|
+
key={selectionKey}
|
|
114
|
+
appear="fade"
|
|
115
|
+
speed="fast"
|
|
116
|
+
className="flex-1 overflow-y-auto p-4 text-body"
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
</Reveal>
|
|
120
|
+
) : (
|
|
121
|
+
<div className="flex-1 overflow-y-auto p-4 text-body">
|
|
122
|
+
<p className="text-muted-foreground">{emptyMessage}</p>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Legend, type LegendProps, type LegendItem } from "./legend";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { Legend } from "./legend";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Flow/Legend",
|
|
6
|
+
component: Legend,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
parameters: { layout: "centered" },
|
|
9
|
+
} satisfies Meta<typeof Legend>;
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
/** Default legend with a title and token-based color references. */
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
args: {
|
|
16
|
+
title: "Node types",
|
|
17
|
+
items: [
|
|
18
|
+
{ label: "Source", color: "var(--primary)" },
|
|
19
|
+
{ label: "Transform", color: "var(--muted-foreground)" },
|
|
20
|
+
{ label: "Output", color: "var(--success)" },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Legend without a title — items only. */
|
|
26
|
+
export const NoTitle: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
items: [
|
|
29
|
+
{ label: "Source", color: "var(--primary)" },
|
|
30
|
+
{ label: "Transform", color: "var(--muted-foreground)" },
|
|
31
|
+
{ label: "Output", color: "var(--success)" },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Richer set of items representing a full pipeline. */
|
|
37
|
+
export const FullPipeline: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
title: "Pipeline stages",
|
|
40
|
+
items: [
|
|
41
|
+
{ label: "Source", color: "var(--primary)" },
|
|
42
|
+
{ label: "Transform", color: "var(--muted-foreground)" },
|
|
43
|
+
{ label: "Validate", color: "var(--warning)" },
|
|
44
|
+
{ label: "Output", color: "var(--success)" },
|
|
45
|
+
{ label: "Error", color: "var(--destructive)" },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Single item. */
|
|
51
|
+
export const SingleItem: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
title: "Key",
|
|
54
|
+
items: [{ label: "Active node", color: "var(--primary)" }],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { Legend } from "./legend";
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
describe("Legend", () => {
|
|
8
|
+
const items = [
|
|
9
|
+
{ label: "Source", color: "var(--chart-1)" },
|
|
10
|
+
{ label: "Target", color: "var(--chart-2)" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
it("renders all item labels", () => {
|
|
14
|
+
render(<Legend items={items} />);
|
|
15
|
+
expect(screen.getByText("Source")).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText("Target")).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders the title when provided", () => {
|
|
20
|
+
render(<Legend items={items} title="Legend Title" />);
|
|
21
|
+
expect(screen.getByText("Legend Title")).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("omits the title when not provided", () => {
|
|
25
|
+
const { container } = render(<Legend items={items} />);
|
|
26
|
+
// No title text in the container beyond the items
|
|
27
|
+
expect(container.querySelector(".font-medium")).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders a color swatch for each item", () => {
|
|
31
|
+
const { container } = render(<Legend items={items} />);
|
|
32
|
+
const swatches = container.querySelectorAll("span[aria-hidden='true']");
|
|
33
|
+
expect(swatches).toHaveLength(2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("applies custom className", () => {
|
|
37
|
+
const { container } = render(<Legend items={items} className="custom-class" />);
|
|
38
|
+
expect(container.firstChild).toHaveClass("custom-class");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders an empty list when items is empty", () => {
|
|
42
|
+
render(<Legend items={[]} />);
|
|
43
|
+
const list = screen.queryByRole("list");
|
|
44
|
+
expect(list).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
2
|
+
|
|
3
|
+
export interface LegendItem {
|
|
4
|
+
label: string;
|
|
5
|
+
/** Any CSS color or token reference, e.g. "var(--chart-1)". */
|
|
6
|
+
color: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LegendProps {
|
|
10
|
+
items: LegendItem[];
|
|
11
|
+
title?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Small legend mapping colors/types to labels for a canvas or chart. */
|
|
16
|
+
export function Legend({ items, title, className }: LegendProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className={cn(
|
|
20
|
+
"rounded-lg bg-surface-elevated/90 p-3 text-xs shadow-ring-sm backdrop-blur",
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
{title ? <div className="mb-1.5 font-medium text-foreground">{title}</div> : null}
|
|
25
|
+
<ul className="space-y-1">
|
|
26
|
+
{items.map((item) => (
|
|
27
|
+
<li key={item.label} className="flex items-center gap-2 text-muted-foreground">
|
|
28
|
+
<span
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
className="size-2.5 shrink-0 rounded-full"
|
|
31
|
+
style={{ backgroundColor: item.color }}
|
|
32
|
+
/>
|
|
33
|
+
{item.label}
|
|
34
|
+
</li>
|
|
35
|
+
))}
|
|
36
|
+
</ul>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|