@tulipfarm/sprout 0.2.0 → 0.3.1

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.
@@ -1,14 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { Background, BaseEdge, EdgeLabelRenderer, getSmoothStepPath, Handle, Panel, Position, ReactFlow, ReactFlowProvider, useNodesInitialized, useReactFlow, useStore, } from "@xyflow/react";
4
- import { useEffect, useId, useMemo, useSyncExternalStore, } from "react";
5
- import { Add } from "reicon-react/icons/Add";
3
+ import { Background, BaseEdge, EdgeLabelRenderer, getSmoothStepPath, Handle, Position, ReactFlow, ReactFlowProvider, } from "@xyflow/react";
4
+ import { useId, useMemo } from "react";
6
5
  import { Key } from "reicon-react/icons/Key";
7
- import { Minus } from "reicon-react/icons/Minus";
8
- import { Scan } from "reicon-react/icons/Scan";
9
6
  import { cn } from "../internal/classes.js";
7
+ import { FitOnResize, flowMaxZoom as maxZoom, flowMinZoom as minZoom, useKeyboardView, useViewportMobile, ZoomControls, } from "../internal/flow.js";
10
8
  import { Badge } from "./badge.js";
11
- import { Button } from "./button.js";
12
9
  export const schemaTableStateNames = [
13
10
  "new",
14
11
  "changed",
@@ -24,10 +21,7 @@ export const schemaDiagramLayoutNames = [
24
21
  const tableWidth = 264;
25
22
  const columnGap = 80;
26
23
  const rowGap = 48;
27
- const minZoom = 0.2;
28
- const maxZoom = 1.5;
29
24
  const fitOptions = { padding: 0.15, maxZoom: 1 };
30
- const panStep = 48;
31
25
  const tableHeight = (table) => 44 +
32
26
  (table.fields.length === 0
33
27
  ? 32
@@ -117,80 +111,13 @@ function LinkLine({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targ
117
111
  }
118
112
  const nodeTypes = { table: TableCard };
119
113
  const edgeTypes = { link: LinkLine };
120
- const mobileQuery = "(max-width: 767px)";
121
- const motionQuery = "(prefers-reduced-motion: reduce)";
122
- function subscribeMobile(callback) {
123
- const media = window.matchMedia(mobileQuery);
124
- media.addEventListener("change", callback);
125
- return () => media.removeEventListener("change", callback);
126
- }
127
- function useViewportMobile() {
128
- return useSyncExternalStore(subscribeMobile, () => window.matchMedia(mobileQuery).matches, () => false);
129
- }
130
- const motion = (ms) => typeof window !== "undefined" && window.matchMedia(motionQuery).matches
131
- ? 0
132
- : ms;
133
- // The canvas panel animates open, so the first fit sees a narrower pane than the final one.
134
- function FitOnResize({ nodes }) {
135
- const { fitView } = useReactFlow();
136
- const width = useStore((state) => state.width);
137
- const height = useStore((state) => state.height);
138
- const measured = useNodesInitialized();
139
- useEffect(() => {
140
- if (!measured)
141
- return;
142
- const frame = requestAnimationFrame(() => void fitView(fitOptions));
143
- return () => cancelAnimationFrame(frame);
144
- }, [fitView, width, height, measured, nodes]);
145
- return null;
146
- }
147
- function ZoomControls({ position, }) {
148
- const { zoomIn, zoomOut, fitView } = useReactFlow();
149
- const zoom = useStore((state) => state.transform[2]);
150
- return (_jsxs(Panel, { position: position, className: "sprout-schema-controls", "data-slot": "schema-diagram-controls", role: "toolbar", "aria-label": "Diagram view", "aria-orientation": "vertical", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Zoom in", title: "Zoom in", disabled: zoom >= maxZoom, onClick: () => void zoomIn({ duration: motion(160) }), children: _jsx(Add, { size: 16, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Zoom out", title: "Zoom out", disabled: zoom <= minZoom, onClick: () => void zoomOut({ duration: motion(160) }), children: _jsx(Minus, { size: 16, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Fit to view", title: "Fit to view", onClick: () => void fitView({ ...fitOptions, duration: motion(180) }), children: _jsx(Scan, { size: 16, "aria-hidden": "true" }) })] }));
151
- }
152
- function useKeyboardView() {
153
- const { getViewport, setViewport, zoomIn, zoomOut, fitView } = useReactFlow();
154
- return (event) => {
155
- const target = event.target;
156
- if (event.altKey || event.ctrlKey || event.metaKey)
157
- return;
158
- if (target !== event.currentTarget &&
159
- !target.classList.contains("react-flow__node"))
160
- return;
161
- const step = panStep * (event.shiftKey ? 4 : 1);
162
- const pan = {
163
- ArrowLeft: [step, 0],
164
- ArrowRight: [-step, 0],
165
- ArrowUp: [0, step],
166
- ArrowDown: [0, -step],
167
- };
168
- const move = pan[event.key];
169
- if (move) {
170
- const { x, y, zoom } = getViewport();
171
- void setViewport({ x: x + move[0], y: y + move[1], zoom }, { duration: motion(120) });
172
- }
173
- else if (event.key === "+" || event.key === "=") {
174
- void zoomIn({ duration: motion(160) });
175
- }
176
- else if (event.key === "-" || event.key === "_") {
177
- void zoomOut({ duration: motion(160) });
178
- }
179
- else if (event.key === "0") {
180
- void fitView({ ...fitOptions, duration: motion(180) });
181
- }
182
- else
183
- return;
184
- event.preventDefault();
185
- };
186
- }
187
114
  const keyboardHint = "Arrow keys pan, plus and minus zoom, and 0 fits the diagram. Tab moves between tables.";
188
115
  function DiagramFlow({ nodes, edges, direction, label, hintId, }) {
189
- const onKeyDown = useKeyboardView();
116
+ const onKeyDown = useKeyboardView(fitOptions);
190
117
  return (_jsxs(ReactFlow, { className: "sprout-schema-flow", "data-slot": "schema-diagram-viewport", "aria-label": label, "aria-describedby": hintId, tabIndex: 0, onKeyDown: onKeyDown, nodes: nodes, edges: edges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, fitView: true, fitViewOptions: fitOptions, minZoom: minZoom, maxZoom: maxZoom, nodesConnectable: false, elementsSelectable: false, edgesFocusable: false, ariaLabelConfig: {
191
118
  "node.a11yDescription.default": keyboardHint,
192
119
  "node.a11yDescription.keyboardDisabled": keyboardHint,
193
- }, proOptions: { hideAttribution: true }, children: [_jsx(Background, { gap: 16, size: 1 }), _jsx(FitOnResize, { nodes: nodes }), _jsx(ZoomControls, { position: direction === "vertical" ? "top-right" : "bottom-right" })] }));
120
+ }, proOptions: { hideAttribution: true }, children: [_jsx(Background, { gap: 16, size: 1 }), _jsx(FitOnResize, { nodes: nodes, fit: fitOptions }), _jsx(ZoomControls, { position: direction === "vertical" ? "top-right" : "bottom-right", fit: fitOptions, className: "sprout-schema-controls", slot: "schema-diagram-controls" })] }));
194
121
  }
195
122
  /**
196
123
  * A pan-and-zoom diagram of tables, their fields, and the links between them,
@@ -0,0 +1,76 @@
1
+ import { type HTMLAttributes } from "react";
2
+ export declare const sqlViewerLayoutNames: readonly ["auto", "horizontal", "vertical"];
3
+ export type SqlViewerLayout = (typeof sqlViewerLayoutNames)[number];
4
+ export type SqlViewerCardinality = "one" | "many";
5
+ export interface SqlViewerOption {
6
+ id: string;
7
+ label: string;
8
+ retired: boolean;
9
+ }
10
+ /** One field of a TulipFarm table definition (format 2) or table document (format 3). */
11
+ export type SqlViewerField = {
12
+ id: string;
13
+ label: string;
14
+ required: boolean;
15
+ } & ({
16
+ kind: "text";
17
+ maxLength: number;
18
+ } | {
19
+ kind: "numeric";
20
+ precision: number;
21
+ scale: number;
22
+ } | {
23
+ kind: "boolean";
24
+ } | {
25
+ kind: "date";
26
+ } | {
27
+ kind: "timestamp";
28
+ timezone?: "UTC";
29
+ } | {
30
+ kind: "single-select" | "multi-select";
31
+ options: readonly SqlViewerOption[];
32
+ } | {
33
+ kind: "relationship";
34
+ /** A table name (format 3) or table UUID (format 2), matching a table's `id`. */
35
+ targetTableId: string;
36
+ cardinality: SqlViewerCardinality;
37
+ inverseCardinality: SqlViewerCardinality;
38
+ reverse?: {
39
+ fieldId: string;
40
+ label: string;
41
+ } | null;
42
+ onDelete?: "restrict";
43
+ } | {
44
+ kind: "file";
45
+ cardinality: SqlViewerCardinality;
46
+ access?: "independent";
47
+ });
48
+ export interface SqlViewerUniqueKey {
49
+ id: string;
50
+ fieldIds: readonly string[];
51
+ }
52
+ /** A TulipFarm table definition or table document. Extra keys are ignored. */
53
+ export interface SqlViewerTable {
54
+ id: string;
55
+ label: string;
56
+ owningTeamId?: string;
57
+ fields: readonly SqlViewerField[];
58
+ uniqueKeys?: readonly SqlViewerUniqueKey[];
59
+ }
60
+ export interface SqlViewerProps extends Omit<HTMLAttributes<HTMLDivElement>, "role" | "children"> {
61
+ /** TulipFarm table definitions or table documents, in display order. */
62
+ tables: readonly SqlViewerTable[];
63
+ /** Names the diagram for assistive tech, such as “Tables in Sales”. */
64
+ label: string;
65
+ /** Team names by `owningTeamId`. Tables of a named team sit in one tinted group. */
66
+ groups?: Readonly<Record<string, string>> | undefined;
67
+ /** Tables shown before the rest collapse into one “+N tables” card. Default 4. */
68
+ maxVisible?: number | undefined;
69
+ /** `auto` stacks tables under 768px and uses columns otherwise. */
70
+ layout?: SqlViewerLayout | undefined;
71
+ }
72
+ /**
73
+ * A pan-and-zoom map of SQL tables, their columns and keys, and the joins
74
+ * between them. Hover or pin a table or a foreign key to trace its joins.
75
+ */
76
+ export declare function SqlViewer({ tables, label, groups, maxVisible, layout, className, ...props }: SqlViewerProps): import("react").JSX.Element;
@@ -0,0 +1,409 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Background, getSmoothStepPath, Handle, Panel, Position, ReactFlow, ReactFlowProvider, useInternalNode, useNodesState, } from "@xyflow/react";
4
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState, } from "react";
5
+ import { Fingerprint } from "reicon-react/icons/Fingerprint";
6
+ import { Key } from "reicon-react/icons/Key";
7
+ import { Layers } from "reicon-react/icons/Layers";
8
+ import { Link } from "reicon-react/icons/Link";
9
+ import { cn } from "../internal/classes.js";
10
+ import { FitOnResize, flowMaxZoom, flowMinZoom, useKeyboardView, useViewportMobile, ZoomControls, } from "../internal/flow.js";
11
+ import { Button } from "./button.js";
12
+ export const sqlViewerLayoutNames = ["auto", "horizontal", "vertical"];
13
+ const moreId = "~more";
14
+ const groupId = (key) => `~group:${key}`;
15
+ // Card geometry. The CSS uses the same numbers so layout never waits for a measure.
16
+ const cardWidth = 272;
17
+ const cardChrome = 4;
18
+ const headerHeight = 40;
19
+ const rowHeight = 28;
20
+ const keysChrome = 36;
21
+ const keyHeight = 26;
22
+ const moreHeight = 132;
23
+ const colGap = 104;
24
+ const rowGap = 32;
25
+ const groupPad = 20;
26
+ const groupHeader = 44;
27
+ const groupGap = 56;
28
+ const wrapWidth = 1400;
29
+ const tones = 5;
30
+ const fitAll = { padding: 0.12, maxZoom: 1 };
31
+ const compositeKeys = (table) => (table.uniqueKeys ?? []).filter((key) => key.fieldIds.length > 1);
32
+ const cardHeight = (table) => {
33
+ const keys = compositeKeys(table).length;
34
+ return (cardChrome +
35
+ headerHeight +
36
+ (table.fields.length + 1) * rowHeight +
37
+ (keys ? keysChrome + keys * keyHeight : 0));
38
+ };
39
+ function columnType(field) {
40
+ switch (field.kind) {
41
+ case "text":
42
+ return `varchar(${field.maxLength})`;
43
+ case "numeric":
44
+ return `numeric(${field.precision},${field.scale})`;
45
+ case "timestamp":
46
+ return "timestamptz";
47
+ case "single-select":
48
+ return "enum";
49
+ case "multi-select":
50
+ return "enum[]";
51
+ case "relationship":
52
+ return field.cardinality === "one" ? "uuid" : "uuid[]";
53
+ case "file":
54
+ return field.cardinality === "one" ? "file" : "file[]";
55
+ default:
56
+ return field.kind;
57
+ }
58
+ }
59
+ function joinText(from, field, to, many) {
60
+ return many
61
+ ? `${to}.id = ANY(${from}.${field})`
62
+ : `${from}.${field} = ${to}.id`;
63
+ }
64
+ /** Places tables in team groups. Linked tables sit one column right of the tables that link to them. */
65
+ function arrange(visible, hidden, groups, direction) {
66
+ const blocks = [];
67
+ for (const table of visible) {
68
+ const team = table.owningTeamId;
69
+ const key = team !== undefined && groups?.[team] ? team : null;
70
+ let block = blocks.find((candidate) => candidate.key === key);
71
+ if (!block) {
72
+ block = { key, label: key ? groups[key] : "", tables: [] };
73
+ blocks.push(block);
74
+ }
75
+ block.tables.push(table);
76
+ }
77
+ const nodes = [];
78
+ let cursorX = 0;
79
+ let cursorY = 0;
80
+ let lineHeight = 0;
81
+ let right = 0;
82
+ let bottom = 0;
83
+ let tone = 0;
84
+ for (const block of blocks) {
85
+ const ids = new Set(block.tables.map((table) => table.id));
86
+ const depth = new Map(block.tables.map((table) => [table.id, 0]));
87
+ if (direction === "horizontal") {
88
+ for (let pass = 0; pass < block.tables.length; pass++) {
89
+ let moved = false;
90
+ for (const table of block.tables)
91
+ for (const field of table.fields) {
92
+ if (field.kind !== "relationship")
93
+ continue;
94
+ const target = field.targetTableId;
95
+ const next = depth.get(table.id) + 1;
96
+ if (target !== table.id &&
97
+ ids.has(target) &&
98
+ next > depth.get(target) &&
99
+ next < block.tables.length) {
100
+ depth.set(target, next);
101
+ moved = true;
102
+ }
103
+ }
104
+ if (!moved)
105
+ break;
106
+ }
107
+ }
108
+ const columnBottoms = new Map();
109
+ const placed = block.tables.map((table) => {
110
+ const column = depth.get(table.id);
111
+ const y = columnBottoms.get(column) ?? 0;
112
+ columnBottoms.set(column, y + cardHeight(table) + rowGap);
113
+ return { table, x: column * (cardWidth + colGap), y };
114
+ });
115
+ const innerWidth = Math.max(...placed.map((item) => item.x)) + cardWidth;
116
+ const innerHeight = Math.max(...columnBottoms.values()) - rowGap;
117
+ const framed = block.key !== null;
118
+ const width = framed ? innerWidth + groupPad * 2 : innerWidth;
119
+ const height = framed ? innerHeight + groupHeader + groupPad : innerHeight;
120
+ if (direction === "horizontal" &&
121
+ cursorX > 0 &&
122
+ cursorX + width > wrapWidth) {
123
+ cursorX = 0;
124
+ cursorY += lineHeight + groupGap;
125
+ lineHeight = 0;
126
+ }
127
+ const blockTone = framed ? (tone++ % tones) + 1 : 0;
128
+ const parent = framed ? groupId(block.key) : undefined;
129
+ if (framed)
130
+ nodes.push({
131
+ id: parent,
132
+ type: "group",
133
+ position: { x: cursorX, y: cursorY },
134
+ width,
135
+ height,
136
+ data: {
137
+ label: block.label,
138
+ tone: blockTone,
139
+ count: block.tables.length,
140
+ },
141
+ dragHandle: ".sprout-sql-group-label",
142
+ focusable: false,
143
+ selectable: false,
144
+ });
145
+ for (const item of placed)
146
+ nodes.push({
147
+ id: item.table.id,
148
+ type: "table",
149
+ position: framed
150
+ ? { x: groupPad + item.x, y: groupHeader + item.y }
151
+ : { x: cursorX + item.x, y: cursorY + item.y },
152
+ ...(parent ? { parentId: parent, expandParent: true } : {}),
153
+ data: { table: item.table, tone: blockTone },
154
+ ariaRole: "group",
155
+ ariaLabel: `${item.table.label} table, ${item.table.fields.length + 1} columns`,
156
+ domAttributes: { "aria-roledescription": "table card" },
157
+ });
158
+ right = Math.max(right, cursorX + width);
159
+ bottom = Math.max(bottom, cursorY + height);
160
+ if (direction === "horizontal") {
161
+ cursorX += width + groupGap;
162
+ lineHeight = Math.max(lineHeight, height);
163
+ }
164
+ else {
165
+ cursorY += height + groupGap;
166
+ }
167
+ }
168
+ if (hidden.length)
169
+ nodes.push({
170
+ id: moreId,
171
+ type: "more",
172
+ position: direction === "horizontal"
173
+ ? { x: right + colGap, y: 0 }
174
+ : { x: 0, y: bottom + groupGap },
175
+ data: { tables: [...hidden] },
176
+ focusable: false,
177
+ });
178
+ return nodes;
179
+ }
180
+ function joins(visible, hidden) {
181
+ const shown = new Set(visible.map((table) => table.id));
182
+ const collapsed = new Set(hidden.map((table) => table.id));
183
+ const labels = new Map([...visible, ...hidden].map((table) => [table.id, table.label]));
184
+ const node = (id) => shown.has(id) ? id : collapsed.has(id) ? moreId : null;
185
+ const edges = [];
186
+ for (const table of [...visible, ...hidden])
187
+ for (const field of table.fields) {
188
+ if (field.kind !== "relationship")
189
+ continue;
190
+ const source = node(table.id);
191
+ const target = node(field.targetTableId);
192
+ if (!source || !target || (source === moreId && target === moreId))
193
+ continue;
194
+ const to = labels.get(field.targetTableId);
195
+ const sourceMany = field.inverseCardinality === "many";
196
+ const targetMany = field.cardinality === "many";
197
+ edges.push({
198
+ id: `${table.id}/${field.id}`,
199
+ type: "join",
200
+ source,
201
+ sourceHandle: source === moreId ? "out:right" : `${field.id}:right`,
202
+ target,
203
+ targetHandle: target === moreId ? "in:left" : "id:left",
204
+ data: {
205
+ join: joinText(table.label, field.label, to, targetMany),
206
+ description: `${table.label}.${field.label} joins ${to}.id, ${sourceMany ? "many" : "one"} to ${targetMany ? "many" : "one"}`,
207
+ sourceMany,
208
+ targetMany,
209
+ },
210
+ });
211
+ }
212
+ return edges;
213
+ }
214
+ const ViewerContext = createContext(null);
215
+ const useViewer = () => useContext(ViewerContext);
216
+ function sideHandles(type, id) {
217
+ return (_jsxs(_Fragment, { children: [_jsx(Handle, { id: `${id}:left`, type: type, position: Position.Left, isConnectable: false, className: "sprout-sql-handle" }), _jsx(Handle, { id: `${id}:right`, type: type, position: Position.Right, isConnectable: false, className: "sprout-sql-handle" })] }));
218
+ }
219
+ function TableCard({ id, data: { table, tone } }) {
220
+ const viewer = useViewer();
221
+ const composite = compositeKeys(table);
222
+ const unique = new Set((table.uniqueKeys ?? [])
223
+ .filter((key) => key.fieldIds.length === 1)
224
+ .map((key) => key.fieldIds[0]));
225
+ const labels = new Map(table.fields.map((field) => [field.id, field.label]));
226
+ const focused = viewer.focus?.table === id;
227
+ return (_jsxs("section", { "data-slot": "sql-table", "data-tone": tone, "data-dimmed": viewer.focus && !viewer.related.has(id) ? "" : undefined, "data-active": focused ? "" : undefined, className: "sprout-sql-table", onMouseLeave: () => viewer.hover(null), children: [_jsx("header", { className: "sprout-sql-table-header", children: _jsx("h3", { className: "sprout-sql-table-title", title: table.label, children: table.label }) }), _jsxs("ul", { className: "sprout-sql-columns", children: [_jsxs("li", { className: "sprout-sql-column", "data-key": "primary", children: [sideHandles("target", "id"), _jsx("span", { className: "sprout-sql-column-icon", children: _jsx(Key, { size: 12, role: "img", "aria-label": "Primary key" }) }), _jsx("span", { className: "sprout-sql-column-name", children: "id" }), _jsx("span", { className: "sprout-sql-column-type", children: "uuid" })] }), table.fields.map((field) => {
228
+ const join = field.kind === "relationship";
229
+ const type = columnType(field);
230
+ return (_jsxs("li", { className: "sprout-sql-column", "data-join": join ? "" : undefined, "data-active": focused && viewer.focus?.field === field.id ? "" : undefined, title: field.kind === "single-select" || field.kind === "multi-select"
231
+ ? field.options
232
+ .filter((option) => !option.retired)
233
+ .map((option) => option.label)
234
+ .join(" · ")
235
+ : undefined, onMouseEnter: join
236
+ ? () => viewer.hover({ table: id, field: field.id })
237
+ : undefined, onMouseLeave: join ? () => viewer.hover({ table: id }) : undefined, onClick: join
238
+ ? (event) => {
239
+ event.stopPropagation();
240
+ viewer.pin({ table: id, field: field.id });
241
+ }
242
+ : undefined, children: [join && sideHandles("source", field.id), _jsx("span", { className: "sprout-sql-column-icon", children: join ? (_jsx(Link, { size: 12, role: "img", "aria-label": "Foreign key" })) : unique.has(field.id) ? (_jsx(Fingerprint, { size: 12, role: "img", "aria-label": "Unique" })) : null }), _jsx("span", { className: "sprout-sql-column-name", title: field.label, children: field.label }), _jsxs("span", { className: "sprout-sql-column-type", children: [type, !field.required && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "?" }), _jsx("span", { className: "sprout-sql-sr-only", children: ", nullable" })] }))] })] }, field.id));
243
+ })] }), composite.length > 0 && (_jsxs("div", { className: "sprout-sql-keys", children: [_jsx("p", { className: "sprout-sql-keys-title", children: "Unique keys" }), _jsx("ul", { className: "sprout-sql-keys-list", children: composite.map((key) => (_jsxs("li", { className: "sprout-sql-key", children: [_jsx(Fingerprint, { size: 12, "aria-hidden": "true" }), _jsx("span", { children: key.fieldIds
244
+ .map((field) => labels.get(field) ?? field)
245
+ .join(", ") })] }, key.id))) })] }))] }));
246
+ }
247
+ function GroupFrame({ data: { label, tone, count } }) {
248
+ return (_jsx("section", { "data-slot": "sql-group", "data-tone": tone, "aria-label": `${label}, ${count} ${count === 1 ? "table" : "tables"}`, className: "sprout-sql-group", children: _jsxs("header", { className: "sprout-sql-group-label", children: [_jsx(Layers, { size: 14, "aria-hidden": "true" }), _jsx("span", { className: "sprout-sql-group-name", children: label }), _jsx("span", { className: "sprout-sql-group-count", children: count })] }) }));
249
+ }
250
+ function MoreCard({ data: { tables } }) {
251
+ const { expand } = useViewer();
252
+ const names = tables.slice(0, 3).map((table) => table.label);
253
+ const rest = tables.length - names.length;
254
+ return (_jsxs("div", { "data-slot": "sql-more", className: "sprout-sql-more", children: [sideHandles("target", "in"), sideHandles("source", "out"), _jsx("span", { "aria-hidden": "true", className: "sprout-sql-more-sheet" }), _jsx("span", { "aria-hidden": "true", className: "sprout-sql-more-sheet" }), _jsxs("button", { type: "button", className: "nodrag sprout-sql-more-button", onClick: expand, "aria-label": `Show ${tables.length} more ${tables.length === 1 ? "table" : "tables"}: ${tables.map((table) => table.label).join(", ")}`, children: [_jsxs("span", { className: "sprout-sql-more-count", children: ["+", tables.length, " ", tables.length === 1 ? "table" : "tables"] }), _jsxs("span", { className: "sprout-sql-more-names", children: [names.join(", "), rest > 0 && ` and ${rest} more`] }), _jsx("span", { className: "sprout-sql-more-action", children: "Show all" })] })] }));
255
+ }
256
+ function handlePoint(node, type, id) {
257
+ const handle = node.internals.handleBounds?.[type]?.find((candidate) => candidate.id === id);
258
+ if (!handle)
259
+ return null;
260
+ return {
261
+ x: node.internals.positionAbsolute.x + handle.x + handle.width / 2,
262
+ y: node.internals.positionAbsolute.y + handle.y + handle.height / 2,
263
+ };
264
+ }
265
+ /** A crow's foot for many, a bar for one, drawn outward from the card edge. */
266
+ function endMark(x, y, side, many) {
267
+ const out = side === "right" ? 1 : -1;
268
+ return many
269
+ ? `M ${x + out * 12} ${y} L ${x} ${y - 6} M ${x + out * 12} ${y} L ${x} ${y + 6}`
270
+ : `M ${x + out * 9} ${y - 5} L ${x + out * 9} ${y + 5}`;
271
+ }
272
+ // Picks the facing sides of the two cards on every render, so links stay tidy while tables are dragged.
273
+ function JoinLine({ id, source, target, sourceHandleId, targetHandleId, data, }) {
274
+ const from = useInternalNode(source);
275
+ const to = useInternalNode(target);
276
+ const viewer = useViewer();
277
+ if (!from || !to || !data || !sourceHandleId || !targetHandleId)
278
+ return null;
279
+ const fromLeft = from.internals.positionAbsolute.x;
280
+ const toLeft = to.internals.positionAbsolute.x;
281
+ const fromRight = fromLeft + (from.measured.width ?? 0);
282
+ const toRight = toLeft + (to.measured.width ?? 0);
283
+ const [sourceSide, targetSide] = fromRight + 24 <= toLeft
284
+ ? ["right", "left"]
285
+ : toRight + 24 <= fromLeft
286
+ ? ["left", "right"]
287
+ : ["right", "right"];
288
+ const base = (handle) => handle.slice(0, handle.lastIndexOf(":"));
289
+ const start = handlePoint(from, "source", `${base(sourceHandleId)}:${sourceSide}`);
290
+ const end = handlePoint(to, "target", `${base(targetHandleId)}:${targetSide}`);
291
+ if (!start || !end)
292
+ return null;
293
+ const position = (side) => side === "right" ? Position.Right : Position.Left;
294
+ const [path] = getSmoothStepPath({
295
+ sourceX: start.x,
296
+ sourceY: start.y,
297
+ sourcePosition: position(sourceSide),
298
+ targetX: end.x,
299
+ targetY: end.y,
300
+ targetPosition: position(targetSide),
301
+ borderRadius: 10,
302
+ offset: 28,
303
+ });
304
+ const active = viewer.activeEdges.has(id);
305
+ return (_jsx("path", { d: `${path} ${endMark(start.x, start.y, sourceSide, data.sourceMany)} ${endMark(end.x, end.y, targetSide, data.targetMany)}`, className: "react-flow__edge-path sprout-sql-join", "data-active": active ? "" : undefined, "data-dimmed": viewer.focus && !active ? "" : undefined }));
306
+ }
307
+ const nodeTypes = { table: TableCard, group: GroupFrame, more: MoreCard };
308
+ const edgeTypes = { join: JoinLine };
309
+ const keyboardHint = "Arrow keys pan, plus and minus zoom, and 0 fits the diagram. Tab moves between tables; Enter pins a table's joins.";
310
+ function ViewerFlow({ layoutNodes, edges, direction, label, hintId, expanded, setExpanded, collapsible, maxVisible, }) {
311
+ const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes);
312
+ useEffect(() => setNodes(layoutNodes), [layoutNodes, setNodes]);
313
+ const [hovered, setHovered] = useState(null);
314
+ const [pinned, setPinned] = useState(null);
315
+ const focus = hovered ?? pinned;
316
+ const pin = useCallback((next) => setPinned((current) => current?.table === next.table && current.field === next.field
317
+ ? null
318
+ : next), []);
319
+ const state = useMemo(() => {
320
+ const activeEdges = new Set();
321
+ const related = new Set();
322
+ if (focus) {
323
+ related.add(focus.table);
324
+ for (const edge of edges) {
325
+ const hit = focus.field
326
+ ? edge.id === `${focus.table}/${focus.field}`
327
+ : edge.source === focus.table || edge.target === focus.table;
328
+ if (!hit)
329
+ continue;
330
+ activeEdges.add(edge.id);
331
+ related.add(edge.source).add(edge.target);
332
+ }
333
+ }
334
+ return {
335
+ focus,
336
+ related,
337
+ activeEdges,
338
+ hover: setHovered,
339
+ pin,
340
+ expand: () => setExpanded(true),
341
+ };
342
+ }, [focus, edges, pin, setExpanded]);
343
+ const shownEdges = useMemo(() => state.activeEdges.size
344
+ ? edges.map((edge) => state.activeEdges.has(edge.id) ? { ...edge, zIndex: 1000 } : edge)
345
+ : edges, [edges, state.activeEdges]);
346
+ // Phones fit the first block to the width and let people pan down; wider panes fit everything.
347
+ const fitFirst = useMemo(() => direction === "vertical" && layoutNodes[0]
348
+ ? { padding: 0.06, maxZoom: 1, nodes: [{ id: layoutNodes[0].id }] }
349
+ : fitAll, [direction, layoutNodes]);
350
+ const keyboardView = useKeyboardView(fitAll);
351
+ const onKeyDown = (event) => {
352
+ const target = event.target;
353
+ const id = target.dataset["id"];
354
+ if ((event.key === "Enter" || event.key === " ") &&
355
+ target.classList.contains("react-flow__node-table") &&
356
+ id) {
357
+ event.preventDefault();
358
+ pin({ table: id });
359
+ return;
360
+ }
361
+ if (event.key === "Escape" && pinned) {
362
+ setPinned(null);
363
+ return;
364
+ }
365
+ keyboardView(event);
366
+ };
367
+ const onFocus = (event) => {
368
+ const id = event.target.dataset["id"];
369
+ if (event.target.classList.contains("react-flow__node-table") && id)
370
+ setHovered({ table: id });
371
+ };
372
+ const onNodeMouseEnter = (_, node) => {
373
+ if (node.type === "table")
374
+ setHovered({ table: node.id });
375
+ };
376
+ const onNodeClick = (_, node) => {
377
+ if (node.type === "table")
378
+ pin({ table: node.id });
379
+ };
380
+ return (_jsx(ViewerContext.Provider, { value: state, children: _jsxs(ReactFlow, { className: "sprout-schema-flow sprout-sql-flow", "data-slot": "sql-viewer-viewport", "data-focus": focus ? "" : undefined, "aria-label": label, "aria-describedby": hintId, tabIndex: 0, onKeyDown: onKeyDown, onFocus: onFocus, onBlur: () => setHovered(null), nodes: nodes, edges: shownEdges, onNodesChange: onNodesChange, nodeTypes: nodeTypes, edgeTypes: edgeTypes, onNodeMouseEnter: onNodeMouseEnter, onNodeClick: onNodeClick, onPaneClick: () => setPinned(null), fitView: true, fitViewOptions: fitFirst, minZoom: flowMinZoom, maxZoom: flowMaxZoom, nodesConnectable: false, elementsSelectable: false, edgesFocusable: false, ariaLabelConfig: {
381
+ "node.a11yDescription.default": keyboardHint,
382
+ "node.a11yDescription.keyboardDisabled": keyboardHint,
383
+ }, proOptions: { hideAttribution: true }, children: [_jsx(Background, { gap: 16, size: 1 }), _jsx(FitOnResize, { nodes: layoutNodes, fit: fitFirst }), collapsible && expanded && (_jsx(Panel, { position: "top-left", "data-slot": "sql-viewer-collapse", children: _jsxs(Button, { variant: "secondary", size: "sm", onClick: () => {
384
+ setPinned(null);
385
+ setExpanded(false);
386
+ }, children: ["Show ", maxVisible, " tables"] }) })), state.activeEdges.size > 0 && (_jsxs(Panel, { position: "bottom-left", "data-slot": "sql-viewer-joins", "aria-hidden": "true", className: "sprout-sql-joins", children: [edges
387
+ .filter((edge) => state.activeEdges.has(edge.id))
388
+ .slice(0, 3)
389
+ .map((edge) => (_jsx("code", { className: "sprout-sql-join-text", children: edge.data.join }, edge.id))), state.activeEdges.size > 3 && (_jsxs("span", { className: "sprout-sql-joins-more", children: ["and ", state.activeEdges.size - 3, " more"] }))] })), _jsx(ZoomControls, { position: direction === "vertical" ? "top-right" : "bottom-right", fit: fitAll, className: "sprout-schema-controls", slot: "sql-viewer-controls" })] }) }));
390
+ }
391
+ /**
392
+ * A pan-and-zoom map of SQL tables, their columns and keys, and the joins
393
+ * between them. Hover or pin a table or a foreign key to trace its joins.
394
+ */
395
+ export function SqlViewer({ tables, label, groups, maxVisible = 4, layout = "auto", className, ...props }) {
396
+ const mobile = useViewportMobile();
397
+ const direction = layout === "auto" ? (mobile ? "vertical" : "horizontal") : layout;
398
+ const hintId = useId();
399
+ const [expanded, setExpanded] = useState(false);
400
+ const limit = Math.max(1, Math.floor(maxVisible));
401
+ const collapsible = tables.length > limit;
402
+ const [visible, hidden] = useMemo(() => collapsible && !expanded
403
+ ? [tables.slice(0, limit), tables.slice(limit)]
404
+ : [tables, []], [tables, limit, collapsible, expanded]);
405
+ const layoutNodes = useMemo(() => arrange(visible, hidden, groups, direction), [visible, hidden, groups, direction]);
406
+ const edges = useMemo(() => joins(visible, hidden), [visible, hidden]);
407
+ const descriptions = joins(tables, []);
408
+ return (_jsxs("div", { ...props, role: "figure", "aria-label": label, "data-slot": "sql-viewer", "data-layout": direction, className: cn("sprout-sql-viewer", className), children: [_jsx("p", { id: hintId, className: "sprout-sql-sr-only", children: keyboardHint }), descriptions.length > 0 && (_jsx("ul", { "aria-label": "Joins", className: "sprout-sql-sr-only", children: descriptions.map((edge) => (_jsx("li", { children: edge.data.description }, edge.id))) })), tables.length === 0 ? (_jsx("p", { className: "sprout-sql-empty", children: "No tables to show" })) : (_jsx(ReactFlowProvider, { children: _jsx(ViewerFlow, { layoutNodes: layoutNodes, edges: edges, direction: direction, label: label, hintId: hintId, expanded: expanded, setExpanded: setExpanded, collapsible: collapsible, maxVisible: limit }) }, direction))] }));
409
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./components/accordion.js";
2
+ export * from "./components/agent-fanout.js";
2
3
  export * from "./components/alert.js";
3
4
  export * from "./components/alert-dialog.js";
4
5
  export * from "./components/aspect-ratio.js";
@@ -53,6 +54,7 @@ export * from "./components/page-header.js";
53
54
  export * from "./components/pagination.js";
54
55
  export * from "./components/plan-outline.js";
55
56
  export * from "./components/popover.js";
57
+ export * from "./components/presence-cursor.js";
56
58
  export * from "./components/progress.js";
57
59
  export * from "./components/prompt-list.js";
58
60
  export * from "./components/questionnaire.js";
@@ -63,6 +65,7 @@ export * from "./components/result-card.js";
63
65
  export * from "./components/review-card.js";
64
66
  export * from "./components/rich-text-editor.js";
65
67
  export * from "./components/schema-diagram.js";
68
+ export * from "./components/sql-viewer.js";
66
69
  export * from "./components/scroll-area.js";
67
70
  export * from "./components/search-dialog.js";
68
71
  export * from "./components/search-field.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./components/accordion.js";
2
+ export * from "./components/agent-fanout.js";
2
3
  export * from "./components/alert.js";
3
4
  export * from "./components/alert-dialog.js";
4
5
  export * from "./components/aspect-ratio.js";
@@ -53,6 +54,7 @@ export * from "./components/page-header.js";
53
54
  export * from "./components/pagination.js";
54
55
  export * from "./components/plan-outline.js";
55
56
  export * from "./components/popover.js";
57
+ export * from "./components/presence-cursor.js";
56
58
  export * from "./components/progress.js";
57
59
  export * from "./components/prompt-list.js";
58
60
  export * from "./components/questionnaire.js";
@@ -63,6 +65,7 @@ export * from "./components/result-card.js";
63
65
  export * from "./components/review-card.js";
64
66
  export * from "./components/rich-text-editor.js";
65
67
  export * from "./components/schema-diagram.js";
68
+ export * from "./components/sql-viewer.js";
66
69
  export * from "./components/scroll-area.js";
67
70
  export * from "./components/search-dialog.js";
68
71
  export * from "./components/search-field.js";