@tulipfarm/sprout 0.2.0 → 0.3.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.
@@ -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";
@@ -0,0 +1,22 @@
1
+ import { type FitViewOptions, type Node } from "@xyflow/react";
2
+ import { type KeyboardEvent } from "react";
3
+ export declare const flowMinZoom = 0.2;
4
+ export declare const flowMaxZoom = 1.5;
5
+ export declare function useViewportMobile(): boolean;
6
+ export declare const motion: (ms: number) => number;
7
+ /**
8
+ * Refits whenever the pane resizes or the nodes change. The canvas panel
9
+ * animates open, so the first fit sees a narrower pane than the final one.
10
+ */
11
+ export declare function FitOnResize({ nodes, fit, }: {
12
+ nodes: readonly Node[];
13
+ fit: FitViewOptions;
14
+ }): null;
15
+ export declare function ZoomControls({ position, fit, className, slot, }: {
16
+ position: "top-right" | "bottom-right";
17
+ fit: FitViewOptions;
18
+ className: string;
19
+ slot: string;
20
+ }): import("react").JSX.Element;
21
+ /** Arrow keys pan, plus and minus zoom, and 0 fits, from the pane or a node. */
22
+ export declare function useKeyboardView(fit: FitViewOptions): (event: KeyboardEvent<HTMLDivElement>) => void;
@@ -0,0 +1,82 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Panel, useNodesInitialized, useReactFlow, useStore, } from "@xyflow/react";
4
+ import { useEffect, useSyncExternalStore } from "react";
5
+ import { Add } from "reicon-react/icons/Add";
6
+ import { Minus } from "reicon-react/icons/Minus";
7
+ import { Scan } from "reicon-react/icons/Scan";
8
+ import { Button } from "../components/button.js";
9
+ export const flowMinZoom = 0.2;
10
+ export const flowMaxZoom = 1.5;
11
+ const panStep = 48;
12
+ const mobileQuery = "(max-width: 767px)";
13
+ const motionQuery = "(prefers-reduced-motion: reduce)";
14
+ function subscribeMobile(callback) {
15
+ const media = window.matchMedia(mobileQuery);
16
+ media.addEventListener("change", callback);
17
+ return () => media.removeEventListener("change", callback);
18
+ }
19
+ export function useViewportMobile() {
20
+ return useSyncExternalStore(subscribeMobile, () => window.matchMedia(mobileQuery).matches, () => false);
21
+ }
22
+ export const motion = (ms) => typeof window !== "undefined" && window.matchMedia(motionQuery).matches
23
+ ? 0
24
+ : ms;
25
+ /**
26
+ * Refits whenever the pane resizes or the nodes change. The canvas panel
27
+ * animates open, so the first fit sees a narrower pane than the final one.
28
+ */
29
+ export function FitOnResize({ nodes, fit, }) {
30
+ const { fitView } = useReactFlow();
31
+ const width = useStore((state) => state.width);
32
+ const height = useStore((state) => state.height);
33
+ const measured = useNodesInitialized();
34
+ useEffect(() => {
35
+ if (!measured)
36
+ return;
37
+ const frame = requestAnimationFrame(() => void fitView(fit));
38
+ return () => cancelAnimationFrame(frame);
39
+ }, [fitView, fit, width, height, measured, nodes]);
40
+ return null;
41
+ }
42
+ export function ZoomControls({ position, fit, className, slot, }) {
43
+ const { zoomIn, zoomOut, fitView } = useReactFlow();
44
+ const zoom = useStore((state) => state.transform[2]);
45
+ return (_jsxs(Panel, { position: position, className: className, "data-slot": slot, 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 >= flowMaxZoom, 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 <= flowMinZoom, 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({ ...fit, duration: motion(180) }), children: _jsx(Scan, { size: 16, "aria-hidden": "true" }) })] }));
46
+ }
47
+ /** Arrow keys pan, plus and minus zoom, and 0 fits, from the pane or a node. */
48
+ export function useKeyboardView(fit) {
49
+ const { getViewport, setViewport, zoomIn, zoomOut, fitView } = useReactFlow();
50
+ return (event) => {
51
+ const target = event.target;
52
+ if (event.altKey || event.ctrlKey || event.metaKey)
53
+ return;
54
+ if (target !== event.currentTarget &&
55
+ !target.classList.contains("react-flow__node"))
56
+ return;
57
+ const step = panStep * (event.shiftKey ? 4 : 1);
58
+ const pan = {
59
+ ArrowLeft: [step, 0],
60
+ ArrowRight: [-step, 0],
61
+ ArrowUp: [0, step],
62
+ ArrowDown: [0, -step],
63
+ };
64
+ const move = pan[event.key];
65
+ if (move) {
66
+ const { x, y, zoom } = getViewport();
67
+ void setViewport({ x: x + move[0], y: y + move[1], zoom }, { duration: motion(120) });
68
+ }
69
+ else if (event.key === "+" || event.key === "=") {
70
+ void zoomIn({ duration: motion(160) });
71
+ }
72
+ else if (event.key === "-" || event.key === "_") {
73
+ void zoomOut({ duration: motion(160) });
74
+ }
75
+ else if (event.key === "0") {
76
+ void fitView({ ...fit, duration: motion(180) });
77
+ }
78
+ else
79
+ return;
80
+ event.preventDefault();
81
+ };
82
+ }