@exellix/diagrams-toolkit 0.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 ADDED
@@ -0,0 +1,95 @@
1
+ # @exellix/diagrams-toolkit
2
+
3
+ Reusable React UI primitives for graph and diagram authoring: library tables, tabs, inspector shell, JSON panels, and an interactive **GraphCanvas** (pan/zoom, drag, SVG edges).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @exellix/diagrams-toolkit react react-dom lucide-react
9
+ ```
10
+
11
+ Peer dependencies: `react`, `react-dom` (^18 or ^19).
12
+
13
+ ## Tailwind CSS (required)
14
+
15
+ Components use Tailwind utility classes. Configure your app to scan the package source.
16
+
17
+ **Tailwind v4** (`src/index.css`):
18
+
19
+ ```css
20
+ @import "tailwindcss";
21
+ @source "../node_modules/@exellix/diagrams-toolkit/src";
22
+ ```
23
+
24
+ **Tailwind v3** (`tailwind.config.js`):
25
+
26
+ ```js
27
+ content: ['src/**/*.{js,jsx}', 'node_modules/@exellix/diagrams-toolkit/src/**/*.{js,jsx}'],
28
+ ```
29
+
30
+ Optional base font helper:
31
+
32
+ ```js
33
+ import '@exellix/diagrams-toolkit/styles.css';
34
+ ```
35
+
36
+ ## GraphCanvas example
37
+
38
+ ```jsx
39
+ import { useState } from 'react';
40
+ import { GraphCanvas } from '@exellix/diagrams-toolkit';
41
+
42
+ export function Demo() {
43
+ const [nodes, setNodes] = useState([
44
+ { id: 'a', label: 'Task A', x: 80, y: 80 },
45
+ { id: 'b', label: 'Task B', x: 400, y: 160 },
46
+ ]);
47
+ const [selectedItem, setSelectedItem] = useState(null);
48
+
49
+ return (
50
+ <div className="h-[480px] flex flex-col">
51
+ <GraphCanvas
52
+ nodes={nodes}
53
+ edges={[{ from: 'a', to: 'b' }]}
54
+ onNodesChange={setNodes}
55
+ selectedItem={selectedItem}
56
+ onSelectItem={setSelectedItem}
57
+ renderNode={(node, { selected, onPointerDown }) => (
58
+ <button
59
+ type="button"
60
+ onPointerDown={onPointerDown}
61
+ className={`absolute p-3 rounded-lg border ${selected ? 'border-indigo-500' : 'border-slate-200'}`}
62
+ style={{ left: node.x, top: node.y }}
63
+ >
64
+ {node.label}
65
+ </button>
66
+ )}
67
+ />
68
+ </div>
69
+ );
70
+ }
71
+ ```
72
+
73
+ ## Exports
74
+
75
+ | Export | Purpose |
76
+ |--------|---------|
77
+ | `GraphCanvas` | Full topology canvas (pan, zoom, drag, edges) |
78
+ | `GraphCanvasZoomControl` | Zoom slider + fit view |
79
+ | `DefaultGraphEdge` | Simple SVG edge renderer |
80
+ | `layoutGraphNodesFromTopology` | Auto-layout + in/out edge counts |
81
+ | `computeEdgeBezierPath` | Edge geometry helper |
82
+ | `FlowCanvas` | Legacy simple flow view (deprecated; use `GraphCanvas`) |
83
+ | `ViewTabStrip`, `ResourceDataTable`, `InspectorShell`, … | Shell UI primitives |
84
+
85
+ ## Monorepo demo
86
+
87
+ From the workspace root:
88
+
89
+ ```bash
90
+ npm run demo:toolkit
91
+ ```
92
+
93
+ ## Publish
94
+
95
+ This package ships raw ESM from `src/` (same pattern as other `@exellix/*` libs in the monorepo).
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@exellix/diagrams-toolkit",
3
+ "version": "0.2.0",
4
+ "description": "Reusable React UI primitives for graph and diagram authoring (tabs, tables, flow canvas, JSON panels).",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20.19.0 || >=22.12.0"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "registry": "https://registry.npmjs.org/"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+ssh://git@github.com/exellix/graphs-studio.git",
16
+ "directory": "packages/diagrams-toolkit"
17
+ },
18
+ "main": "./src/index.js",
19
+ "exports": {
20
+ ".": "./src/index.js",
21
+ "./styles.css": "./src/styles.css"
22
+ },
23
+ "files": [
24
+ "src"
25
+ ],
26
+ "peerDependencies": {
27
+ "react": "^18.0.0 || ^19.0.0",
28
+ "react-dom": "^18.0.0 || ^19.0.0"
29
+ },
30
+ "dependencies": {
31
+ "lucide-react": "^1.21.0"
32
+ },
33
+ "devDependencies": {
34
+ "react": "^19.2.7",
35
+ "react-dom": "^19.2.7"
36
+ },
37
+ "scripts": {
38
+ "demo": "npm run dev -w diagrams-toolkit-demo",
39
+ "prepublishOnly": "node --test tests/diagrams-toolkit.test.mjs"
40
+ }
41
+ }
@@ -0,0 +1,75 @@
1
+ import React from 'react';
2
+ import { ChevronDown, ChevronRight } from 'lucide-react';
3
+ import { StatusBadge } from './StatusBadge.jsx';
4
+
5
+ /**
6
+ * @param {{
7
+ * title?: string,
8
+ * typeLabel?: string,
9
+ * intentLabel?: string,
10
+ * objective?: string,
11
+ * status?: string,
12
+ * collapsed?: boolean,
13
+ * onToggleCollapsed?: () => void,
14
+ * onOpenConcept?: () => void,
15
+ * className?: string,
16
+ * }} props
17
+ */
18
+ export function ConceptSummaryStrip({
19
+ title,
20
+ typeLabel,
21
+ intentLabel,
22
+ objective,
23
+ status = 'draft',
24
+ collapsed = false,
25
+ onToggleCollapsed,
26
+ onOpenConcept,
27
+ className = '',
28
+ }) {
29
+ const chipText = typeLabel
30
+ ? `${typeLabel}${intentLabel ? ` · ${intentLabel}` : ''}`
31
+ : objective?.slice(0, 60) || title || 'Concept';
32
+
33
+ return (
34
+ <div
35
+ className={`shrink-0 border-b border-slate-200 bg-white px-3 py-2 flex items-center gap-2 ${className}`}
36
+ >
37
+ {onToggleCollapsed ? (
38
+ <button
39
+ type="button"
40
+ onClick={onToggleCollapsed}
41
+ className="p-1 rounded hover:bg-slate-100 text-slate-500"
42
+ aria-expanded={!collapsed}
43
+ >
44
+ {collapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
45
+ </button>
46
+ ) : null}
47
+ <StatusBadge status={status} />
48
+ {collapsed ? (
49
+ <button
50
+ type="button"
51
+ onClick={onOpenConcept}
52
+ className="text-sm text-slate-700 truncate hover:text-indigo-700"
53
+ >
54
+ {chipText}
55
+ </button>
56
+ ) : (
57
+ <div className="flex-1 min-w-0 text-sm text-slate-700">
58
+ {title ? <span className="font-medium">{title}</span> : null}
59
+ {objective ? (
60
+ <p className="text-xs text-slate-500 truncate mt-0.5">{objective}</p>
61
+ ) : null}
62
+ </div>
63
+ )}
64
+ {onOpenConcept ? (
65
+ <button
66
+ type="button"
67
+ onClick={onOpenConcept}
68
+ className="text-xs font-medium text-indigo-600 hover:text-indigo-800 shrink-0"
69
+ >
70
+ Open concept
71
+ </button>
72
+ ) : null}
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,57 @@
1
+ import React from 'react';
2
+ import { computeEdgeBezierPath } from './lib/computeEdgeBezierPath.js';
3
+
4
+ /**
5
+ * Simple topology edge with optional label.
6
+ *
7
+ * @param {{
8
+ * edge: { from: string, to: string, label?: string },
9
+ * sourceNode: { x: number, y: number },
10
+ * targetNode: { x: number, y: number },
11
+ * selected?: boolean,
12
+ * filteredOut?: boolean,
13
+ * onClick?: (edge: object) => void,
14
+ * nodeWidth?: number,
15
+ * anchorY?: number,
16
+ * }} props
17
+ */
18
+ export function DefaultGraphEdge({
19
+ edge,
20
+ sourceNode,
21
+ targetNode,
22
+ selected = false,
23
+ filteredOut = false,
24
+ onClick,
25
+ nodeWidth,
26
+ anchorY,
27
+ }) {
28
+ if (!sourceNode || !targetNode) return null;
29
+
30
+ const { path, midX, midY } = computeEdgeBezierPath(sourceNode, targetNode, { nodeWidth, anchorY });
31
+ const stroke = selected ? '#4f46e5' : '#cbd5e1';
32
+ const marker = selected ? 'selected' : 'normal';
33
+
34
+ return (
35
+ <g
36
+ className={`cursor-pointer pointer-events-auto edge-element ${filteredOut ? 'opacity-20' : ''}`}
37
+ onClick={(e) => {
38
+ e.stopPropagation();
39
+ onClick?.(edge);
40
+ }}
41
+ >
42
+ <path d={path} fill="none" stroke="transparent" strokeWidth="20" />
43
+ <path
44
+ d={path}
45
+ fill="none"
46
+ stroke={stroke}
47
+ strokeWidth={selected ? '3' : '2'}
48
+ markerEnd={`url(#arrowhead-${marker})`}
49
+ />
50
+ {edge.label ? (
51
+ <text x={midX} y={midY - 6} fontSize={10} fill="#64748b" textAnchor="middle">
52
+ {edge.label}
53
+ </text>
54
+ ) : null}
55
+ </g>
56
+ );
57
+ }
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * @param {{ title: string, detail?: string, action?: React.ReactNode, className?: string }} props
5
+ */
6
+ export function EmptyState({ title, detail, action, className = '' }) {
7
+ return (
8
+ <div
9
+ className={`flex flex-col items-center justify-center text-center px-4 py-12 ${className}`}
10
+ >
11
+ <p className="text-sm font-medium text-slate-700">{title}</p>
12
+ {detail ? <p className="text-sm text-slate-500 mt-2 max-w-md">{detail}</p> : null}
13
+ {action ? <div className="mt-4">{action}</div> : null}
14
+ </div>
15
+ );
16
+ }
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import { AlertCircle } from 'lucide-react';
3
+
4
+ /**
5
+ * @param {{ title: string, detail?: string, action?: React.ReactNode, className?: string }} props
6
+ */
7
+ export function ErrorState({ title, detail, action, className = '' }) {
8
+ return (
9
+ <div
10
+ className={`flex flex-col items-center justify-center text-center px-4 py-12 bg-slate-100 ${className}`}
11
+ >
12
+ <AlertCircle className="w-8 h-8 text-red-500 mb-3" aria-hidden />
13
+ <p className="text-sm font-medium text-slate-700">{title}</p>
14
+ {detail ? (
15
+ <p className="text-xs font-mono text-red-700 mt-2 break-all max-w-lg">{detail}</p>
16
+ ) : null}
17
+ {action ? <div className="mt-4">{action}</div> : null}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,103 @@
1
+ import React, { useMemo } from 'react';
2
+
3
+ /**
4
+ * @typedef {{ id: string, label: string, role?: string, x?: number, y?: number, meta?: Record<string, unknown> }} FlowNode
5
+ * @typedef {{ id: string, from: string, to: string, label?: string }} FlowEdge
6
+ */
7
+
8
+ /**
9
+ * @deprecated Use {@link GraphCanvas} for interactive pan/zoom/drag canvas.
10
+ * @param {{
11
+ * nodes: FlowNode[],
12
+ * edges: FlowEdge[],
13
+ * selectedId?: string | null,
14
+ * onSelect?: (id: string | null) => void,
15
+ * renderNode?: (node: FlowNode, selected: boolean) => React.ReactNode,
16
+ * className?: string,
17
+ * }} props
18
+ */
19
+ export function FlowCanvas({
20
+ nodes = [],
21
+ edges = [],
22
+ selectedId = null,
23
+ onSelect,
24
+ renderNode,
25
+ className = '',
26
+ }) {
27
+ const layout = useMemo(() => {
28
+ const positioned = nodes.map((n, i) => ({
29
+ ...n,
30
+ x: typeof n.x === 'number' ? n.x : 40 + (i % 3) * 220,
31
+ y: typeof n.y === 'number' ? n.y : 40 + Math.floor(i / 3) * 120,
32
+ }));
33
+ const byId = new Map(positioned.map((n) => [n.id, n]));
34
+ return { positioned, byId };
35
+ }, [nodes]);
36
+
37
+ return (
38
+ <div className={`relative flex-1 min-h-0 overflow-auto bg-slate-100 ${className}`}>
39
+ <svg className="absolute inset-0 w-full h-full pointer-events-none min-h-[400px]">
40
+ {edges.map((e) => {
41
+ const from = layout.byId.get(e.from);
42
+ const to = layout.byId.get(e.to);
43
+ if (!from || !to) return null;
44
+ const x1 = from.x + 90;
45
+ const y1 = from.y + 28;
46
+ const x2 = to.x + 10;
47
+ const y2 = to.y + 28;
48
+ return (
49
+ <g key={e.id}>
50
+ <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#94a3b8" strokeWidth={2} markerEnd="url(#arrow)" />
51
+ {e.label ? (
52
+ <text x={(x1 + x2) / 2} y={(y1 + y2) / 2 - 6} fontSize={10} fill="#64748b" textAnchor="middle">
53
+ {e.label}
54
+ </text>
55
+ ) : null}
56
+ </g>
57
+ );
58
+ })}
59
+ <defs>
60
+ <marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
61
+ <path d="M0,0 L6,3 L0,6 Z" fill="#94a3b8" />
62
+ </marker>
63
+ </defs>
64
+ </svg>
65
+ <div className="relative min-h-[400px] p-4">
66
+ {layout.positioned.map((node) => {
67
+ const selected = node.id === selectedId;
68
+ if (renderNode) {
69
+ return (
70
+ <div
71
+ key={node.id}
72
+ className="absolute"
73
+ style={{ left: node.x, top: node.y }}
74
+ onClick={() => onSelect?.(node.id)}
75
+ >
76
+ {renderNode(node, selected)}
77
+ </div>
78
+ );
79
+ }
80
+ return (
81
+ <button
82
+ key={node.id}
83
+ type="button"
84
+ onClick={() => onSelect?.(node.id)}
85
+ className={`absolute w-[180px] text-left p-3 rounded-lg border shadow-sm transition-colors ${
86
+ selected
87
+ ? 'border-indigo-500 bg-indigo-50 ring-2 ring-indigo-200'
88
+ : 'border-slate-200 bg-white hover:border-slate-300'
89
+ }`}
90
+ style={{ left: node.x, top: node.y }}
91
+ >
92
+ <div className="text-xs font-mono text-slate-400 truncate">{node.id}</div>
93
+ <div className="text-sm font-medium text-slate-800 truncate">{node.label}</div>
94
+ {node.role ? (
95
+ <div className="text-[10px] uppercase tracking-wide text-slate-500 mt-1">{node.role}</div>
96
+ ) : null}
97
+ </button>
98
+ );
99
+ })}
100
+ </div>
101
+ </div>
102
+ );
103
+ }