@vertly/dashboard-grid 0.1.0 → 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.
@@ -0,0 +1,608 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { Fragment as Fragment2, useCallback, useRef, useState } from "react";
4
+ import { DndProvider, useDrag, useDragLayer, useDrop } from "react-dnd";
5
+ import { HTML5Backend } from "react-dnd-html5-backend";
6
+ import { GripVerticalIcon } from "lucide-react";
7
+ import {
8
+ COLS,
9
+ MAX_ITEMS_PER_ROW,
10
+ MAX_ROW_HEIGHT,
11
+ MIN_ROW_HEIGHT,
12
+ ZONE_WIDTH_PX,
13
+ moveRow,
14
+ placeItemAt,
15
+ placeItemInNewRow,
16
+ removeItemFromRow,
17
+ resizeItemPair,
18
+ resizeRow
19
+ } from "./layout-engine";
20
+ import { cn } from "./cn";
21
+ const DEFAULT_SAVE_DEBOUNCE_MS = 600;
22
+ const ItemTypes = { WIDGET: "widget", ROW: "row" };
23
+ const EXTERNAL_ITEM_TYPE = "vertly-dashboard-grid:external-item";
24
+ function DashboardGrid({
25
+ rows: initialRows,
26
+ widgets,
27
+ readOnly = false,
28
+ saveDebounceMs = DEFAULT_SAVE_DEBOUNCE_MS,
29
+ onUpdateLayout,
30
+ onDeleteWidget,
31
+ onDropExternalItem,
32
+ onCreateRowExternalItem,
33
+ renderRowEdgeAdd,
34
+ renderWidgetMenu,
35
+ renderWidgetConfigDialog
36
+ }) {
37
+ const [{ rows, items }, setState] = useState(() => ({
38
+ rows: initialRows.map((row) => ({
39
+ id: row.id,
40
+ heightPx: row.heightPx,
41
+ widgetIds: row.widgetIds
42
+ })),
43
+ items: Object.fromEntries(
44
+ Object.values(widgets).map((w) => [w.id, { id: w.id, colSpan: w.colSpan }])
45
+ )
46
+ }));
47
+ const saveTimer = useRef(null);
48
+ const persist = useCallback(
49
+ (nextRows, nextItems) => {
50
+ if (readOnly) return;
51
+ if (saveTimer.current) clearTimeout(saveTimer.current);
52
+ saveTimer.current = setTimeout(() => {
53
+ onUpdateLayout?.(
54
+ nextRows.map((row) => ({ id: row.id, heightPx: row.heightPx, widgetIds: row.widgetIds })),
55
+ Object.values(nextItems).map((item) => ({ id: item.id, colSpan: item.colSpan }))
56
+ );
57
+ }, saveDebounceMs);
58
+ },
59
+ [readOnly, saveDebounceMs, onUpdateLayout]
60
+ );
61
+ const placeWidgetAt = useCallback(
62
+ (opts, targetRowId, index) => {
63
+ setState((prev) => {
64
+ const next = placeItemAt(
65
+ prev.rows,
66
+ prev.items,
67
+ opts.widgetId,
68
+ opts.fromRowId,
69
+ targetRowId,
70
+ index
71
+ );
72
+ persist(next.rows, next.items);
73
+ return next;
74
+ });
75
+ },
76
+ [persist]
77
+ );
78
+ const createRowWith = useCallback(
79
+ (opts) => {
80
+ setState((prev) => {
81
+ const next = placeItemInNewRow(
82
+ prev.rows,
83
+ prev.items,
84
+ opts.widgetId,
85
+ opts.fromRowId,
86
+ `row-${crypto.randomUUID()}`
87
+ );
88
+ persist(next.rows, next.items);
89
+ return next;
90
+ });
91
+ },
92
+ [persist]
93
+ );
94
+ const removeWidget = useCallback(
95
+ (widgetId, rowId) => {
96
+ setState((prev) => {
97
+ const next = removeItemFromRow(prev.rows, prev.items, widgetId, rowId);
98
+ persist(next.rows, next.items);
99
+ return next;
100
+ });
101
+ onDeleteWidget?.(widgetId);
102
+ },
103
+ [persist, onDeleteWidget]
104
+ );
105
+ const resizeWidgetPair = useCallback(
106
+ (leftId, rightId, nextLeftColSpan) => {
107
+ setState((prev) => {
108
+ const nextItems = resizeItemPair(prev.items, leftId, rightId, nextLeftColSpan);
109
+ persist(prev.rows, nextItems);
110
+ return { rows: prev.rows, items: nextItems };
111
+ });
112
+ },
113
+ [persist]
114
+ );
115
+ const resizeRowHeight = useCallback(
116
+ (rowId, heightPx) => {
117
+ setState((prev) => {
118
+ const nextRows = resizeRow(prev.rows, rowId, heightPx);
119
+ persist(nextRows, prev.items);
120
+ return { rows: nextRows, items: prev.items };
121
+ });
122
+ },
123
+ [persist]
124
+ );
125
+ const moveRowTo = useCallback(
126
+ (from, to) => {
127
+ setState((prev) => {
128
+ const nextRows = moveRow(prev.rows, from, to);
129
+ persist(nextRows, prev.items);
130
+ return { rows: nextRows, items: prev.items };
131
+ });
132
+ },
133
+ [persist]
134
+ );
135
+ return /* @__PURE__ */ jsx(DndProvider, { backend: HTML5Backend, children: /* @__PURE__ */ jsx(
136
+ Board,
137
+ {
138
+ rows,
139
+ items,
140
+ widgets,
141
+ readOnly,
142
+ onDropExisting: placeWidgetAt,
143
+ onCreateRowExisting: createRowWith,
144
+ onRemoveWidget: removeWidget,
145
+ onResizeWidget: resizeWidgetPair,
146
+ onResizeRow: resizeRowHeight,
147
+ onMoveRow: moveRowTo,
148
+ onDropExternalItem,
149
+ onCreateRowExternalItem,
150
+ renderRowEdgeAdd,
151
+ renderWidgetMenu,
152
+ renderWidgetConfigDialog
153
+ }
154
+ ) });
155
+ }
156
+ function Board({
157
+ rows,
158
+ items,
159
+ widgets,
160
+ readOnly,
161
+ onDropExisting,
162
+ onCreateRowExisting,
163
+ onRemoveWidget,
164
+ onResizeWidget,
165
+ onResizeRow,
166
+ onMoveRow,
167
+ onDropExternalItem,
168
+ onCreateRowExternalItem,
169
+ renderRowEdgeAdd,
170
+ renderWidgetMenu,
171
+ renderWidgetConfigDialog
172
+ }) {
173
+ const dragActive = useDragLayer((monitor) => monitor.isDragging());
174
+ return /* @__PURE__ */ jsxs("div", { className: "flex w-full flex-col gap-4", children: [
175
+ rows.map((row, index) => /* @__PURE__ */ jsx(
176
+ SortableRow,
177
+ {
178
+ row,
179
+ index,
180
+ items,
181
+ widgets,
182
+ readOnly,
183
+ dragActive,
184
+ onDropExisting,
185
+ onRemoveWidget,
186
+ onResizeWidget,
187
+ onResizeRow,
188
+ onMoveRow,
189
+ onDropExternalItem,
190
+ renderRowEdgeAdd,
191
+ renderWidgetMenu,
192
+ renderWidgetConfigDialog
193
+ },
194
+ row.id
195
+ )),
196
+ !readOnly ? /* @__PURE__ */ jsx(
197
+ NewRowDropZone,
198
+ {
199
+ onDropExisting: onCreateRowExisting,
200
+ onCreateRowExternalItem
201
+ }
202
+ ) : null
203
+ ] });
204
+ }
205
+ function NewRowDropZone({
206
+ onDropExisting,
207
+ onCreateRowExternalItem
208
+ }) {
209
+ const [{ isOver }, dropRef] = useDrop(
210
+ () => ({
211
+ accept: onCreateRowExternalItem ? [ItemTypes.WIDGET, EXTERNAL_ITEM_TYPE] : ItemTypes.WIDGET,
212
+ drop: (item, monitor) => {
213
+ if (monitor.getItemType() === EXTERNAL_ITEM_TYPE) {
214
+ onCreateRowExternalItem?.(item);
215
+ } else {
216
+ onDropExisting(item);
217
+ }
218
+ },
219
+ collect: (monitor) => ({ isOver: monitor.isOver() })
220
+ }),
221
+ [onDropExisting, onCreateRowExternalItem]
222
+ );
223
+ return /* @__PURE__ */ jsx(
224
+ "div",
225
+ {
226
+ ref: (node) => {
227
+ dropRef(node);
228
+ },
229
+ className: cn(
230
+ "flex h-12 items-center justify-center rounded-lg border-2 border-dashed border-border text-xs text-muted-foreground transition-colors",
231
+ isOver && "border-primary bg-primary/5 text-primary"
232
+ ),
233
+ children: "Drop here to start a new row"
234
+ }
235
+ );
236
+ }
237
+ function SortableRow({
238
+ row,
239
+ index,
240
+ items,
241
+ widgets,
242
+ readOnly,
243
+ dragActive,
244
+ onDropExisting,
245
+ onRemoveWidget,
246
+ onResizeWidget,
247
+ onResizeRow,
248
+ onMoveRow,
249
+ onDropExternalItem,
250
+ renderRowEdgeAdd,
251
+ renderWidgetMenu,
252
+ renderWidgetConfigDialog
253
+ }) {
254
+ const [{ isDragging }, dragRef, previewRef] = useDrag(
255
+ () => ({
256
+ type: ItemTypes.ROW,
257
+ item: { rowId: row.id, index },
258
+ canDrag: !readOnly,
259
+ collect: (monitor) => ({ isDragging: monitor.isDragging() })
260
+ }),
261
+ [row.id, index, readOnly]
262
+ );
263
+ const [, dropRef] = useDrop(
264
+ () => ({
265
+ accept: ItemTypes.ROW,
266
+ canDrop: () => !readOnly,
267
+ hover: (item) => {
268
+ if (readOnly || item.index === index) return;
269
+ onMoveRow(item.index, index);
270
+ item.index = index;
271
+ }
272
+ }),
273
+ [index, readOnly, onMoveRow]
274
+ );
275
+ const setRowRef = useCallback(
276
+ (node) => {
277
+ previewRef(node);
278
+ dropRef(node);
279
+ },
280
+ [previewRef, dropRef]
281
+ );
282
+ return /* @__PURE__ */ jsxs(
283
+ "div",
284
+ {
285
+ ref: setRowRef,
286
+ style: { height: row.heightPx },
287
+ className: cn(
288
+ "group/row relative flex items-stretch gap-2 rounded-lg bg-muted/30 p-2 transition-opacity",
289
+ isDragging && "opacity-40"
290
+ ),
291
+ children: [
292
+ !readOnly ? /* @__PURE__ */ jsx(
293
+ "div",
294
+ {
295
+ ref: (node) => {
296
+ dragRef(node);
297
+ },
298
+ className: "flex w-6 shrink-0 cursor-grab items-center justify-center text-muted-foreground active:cursor-grabbing",
299
+ title: "Drag to reorder row",
300
+ children: /* @__PURE__ */ jsx(GripVerticalIcon, { className: "size-4" })
301
+ }
302
+ ) : null,
303
+ !readOnly && row.widgetIds.length < MAX_ITEMS_PER_ROW ? /* @__PURE__ */ jsx(RowEdgeAddWidget, { rowId: row.id, edge: "start", render: renderRowEdgeAdd }) : null,
304
+ /* @__PURE__ */ jsx(
305
+ RowContent,
306
+ {
307
+ row,
308
+ items,
309
+ widgets,
310
+ readOnly,
311
+ dragActive,
312
+ onDropExisting,
313
+ onRemoveWidget,
314
+ onResizeWidget,
315
+ onDropExternalItem,
316
+ renderWidgetMenu,
317
+ renderWidgetConfigDialog
318
+ }
319
+ ),
320
+ !readOnly && row.widgetIds.length < MAX_ITEMS_PER_ROW ? /* @__PURE__ */ jsx(RowEdgeAddWidget, { rowId: row.id, edge: "end", render: renderRowEdgeAdd }) : null,
321
+ !readOnly ? /* @__PURE__ */ jsx(RowResizeHandle, { rowId: row.id, heightPx: row.heightPx, onResize: onResizeRow }) : null
322
+ ]
323
+ }
324
+ );
325
+ }
326
+ function RowEdgeAddWidget({
327
+ rowId,
328
+ edge,
329
+ render
330
+ }) {
331
+ if (!render) return null;
332
+ return /* @__PURE__ */ jsx(Fragment, { children: render({ rowId, edge }) });
333
+ }
334
+ function RowContent({
335
+ row,
336
+ items,
337
+ widgets,
338
+ readOnly,
339
+ dragActive,
340
+ onDropExisting,
341
+ onRemoveWidget,
342
+ onResizeWidget,
343
+ onDropExternalItem,
344
+ renderWidgetMenu,
345
+ renderWidgetConfigDialog
346
+ }) {
347
+ const containerRef = useRef(null);
348
+ return /* @__PURE__ */ jsxs("div", { ref: containerRef, className: "flex h-full min-w-0 flex-1 items-stretch", children: [
349
+ !readOnly ? /* @__PURE__ */ jsx(
350
+ InsertionZone,
351
+ {
352
+ rowId: row.id,
353
+ index: 0,
354
+ widgetCount: row.widgetIds.length,
355
+ dragActive,
356
+ onDropExisting,
357
+ onDropExternalItem
358
+ }
359
+ ) : /* @__PURE__ */ jsx(ZoneSpacer, {}),
360
+ row.widgetIds.map((widgetId, i) => {
361
+ const item = items[widgetId];
362
+ const widget = widgets[widgetId];
363
+ if (!item || !widget) return null;
364
+ return (
365
+ // A Fragment, not a wrapping div — PlacedWidget must be a direct
366
+ // flex child of the row container above, since its percentage
367
+ // width resolves against its immediate parent. A wrapper div here
368
+ // (with no definite width of its own) would make that percentage
369
+ // resolve against an indefinite size and collapse to content
370
+ // width instead.
371
+ /* @__PURE__ */ jsxs(Fragment2, { children: [
372
+ /* @__PURE__ */ jsx(
373
+ PlacedWidget,
374
+ {
375
+ item,
376
+ widget,
377
+ rowId: row.id,
378
+ widgetCount: row.widgetIds.length,
379
+ readOnly,
380
+ onRemoveWidget,
381
+ renderWidgetMenu,
382
+ renderWidgetConfigDialog
383
+ }
384
+ ),
385
+ !readOnly ? /* @__PURE__ */ jsx(
386
+ InsertionZone,
387
+ {
388
+ rowId: row.id,
389
+ index: i + 1,
390
+ widgetCount: row.widgetIds.length,
391
+ dragActive,
392
+ leftWidgetId: widgetId,
393
+ rightWidgetId: row.widgetIds[i + 1],
394
+ leftColSpan: item.colSpan,
395
+ containerRef,
396
+ onDropExisting,
397
+ onDropExternalItem,
398
+ onResizeWidget
399
+ }
400
+ ) : /* @__PURE__ */ jsx(ZoneSpacer, {})
401
+ ] }, widgetId)
402
+ );
403
+ })
404
+ ] });
405
+ }
406
+ function ZoneSpacer() {
407
+ return /* @__PURE__ */ jsx("div", { style: { width: ZONE_WIDTH_PX }, className: "shrink-0" });
408
+ }
409
+ function InsertionZone({
410
+ rowId,
411
+ index,
412
+ widgetCount,
413
+ dragActive,
414
+ leftWidgetId,
415
+ rightWidgetId,
416
+ leftColSpan,
417
+ containerRef,
418
+ onDropExisting,
419
+ onDropExternalItem,
420
+ onResizeWidget
421
+ }) {
422
+ const [{ isOver, canDrop }, dropRef] = useDrop(
423
+ () => ({
424
+ accept: onDropExternalItem ? [ItemTypes.WIDGET, EXTERNAL_ITEM_TYPE] : ItemTypes.WIDGET,
425
+ // A same-row reorder never grows the row, so it's always allowed —
426
+ // only a drop that would add a net-new member to an already-full row
427
+ // is rejected (mirrors the authoritative check in placeItemAt). A
428
+ // dropped external item is always a net-new member (there's nothing
429
+ // to reorder), so it only ever gets the row-not-full check.
430
+ canDrop: (item, monitor) => {
431
+ if (monitor.getItemType() === EXTERNAL_ITEM_TYPE) {
432
+ return widgetCount < MAX_ITEMS_PER_ROW;
433
+ }
434
+ if (widgetCount < MAX_ITEMS_PER_ROW) return true;
435
+ return item.fromRowId === rowId;
436
+ },
437
+ drop: (item, monitor) => {
438
+ if (monitor.getItemType() === EXTERNAL_ITEM_TYPE) {
439
+ onDropExternalItem?.(item, rowId, index);
440
+ } else {
441
+ onDropExisting(item, rowId, index);
442
+ }
443
+ },
444
+ collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop() })
445
+ }),
446
+ [rowId, index, widgetCount, onDropExisting, onDropExternalItem]
447
+ );
448
+ const isResizable = leftWidgetId !== void 0 && rightWidgetId !== void 0;
449
+ const handleMouseDown = useCallback(
450
+ (event) => {
451
+ if (leftWidgetId === void 0 || rightWidgetId === void 0 || leftColSpan === void 0 || !containerRef?.current || !onResizeWidget) {
452
+ return;
453
+ }
454
+ event.preventDefault();
455
+ event.stopPropagation();
456
+ const containerWidth = containerRef.current.getBoundingClientRect().width;
457
+ if (!containerWidth) return;
458
+ const zonesWidthPx = (widgetCount + 1) * ZONE_WIDTH_PX;
459
+ const colWidthPx = (containerWidth - zonesWidthPx) / COLS;
460
+ const startX = event.clientX;
461
+ const startLeftColSpan = leftColSpan;
462
+ const resolvedOnResize = onResizeWidget;
463
+ const resolvedLeftId = leftWidgetId;
464
+ const resolvedRightId = rightWidgetId;
465
+ function handleMove(moveEvent) {
466
+ const deltaCols = Math.round((moveEvent.clientX - startX) / colWidthPx);
467
+ resolvedOnResize(resolvedLeftId, resolvedRightId, startLeftColSpan + deltaCols);
468
+ }
469
+ function handleUp() {
470
+ window.removeEventListener("mousemove", handleMove);
471
+ window.removeEventListener("mouseup", handleUp);
472
+ }
473
+ window.addEventListener("mousemove", handleMove);
474
+ window.addEventListener("mouseup", handleUp);
475
+ },
476
+ [leftWidgetId, rightWidgetId, leftColSpan, containerRef, widgetCount, onResizeWidget]
477
+ );
478
+ return /* @__PURE__ */ jsx(
479
+ "div",
480
+ {
481
+ ref: (node) => {
482
+ dropRef(node);
483
+ },
484
+ onMouseDown: handleMouseDown,
485
+ style: { width: ZONE_WIDTH_PX },
486
+ className: cn(
487
+ // Fixed footprint at all times — the "grows on hover" affordance
488
+ // comes from a scale transform on the inner pill, not a width
489
+ // change, so it can't perturb every other widget's calc()'d width
490
+ // mid-drag.
491
+ "flex h-full shrink-0 items-center justify-center",
492
+ isResizable && "cursor-ew-resize"
493
+ ),
494
+ children: /* @__PURE__ */ jsx(
495
+ "div",
496
+ {
497
+ className: cn(
498
+ "h-full w-full rounded-full transition-all",
499
+ isOver && canDrop ? "scale-x-150 bg-primary/25 ring-2 ring-primary" : dragActive && canDrop ? "ring-dashed bg-muted-foreground/20 ring-1 ring-border" : isResizable ? "bg-transparent hover:bg-muted-foreground/20 hover:ring-1 hover:ring-border" : "bg-transparent"
500
+ )
501
+ }
502
+ )
503
+ }
504
+ );
505
+ }
506
+ function PlacedWidget({
507
+ item,
508
+ widget,
509
+ rowId,
510
+ widgetCount,
511
+ readOnly,
512
+ onRemoveWidget,
513
+ renderWidgetMenu,
514
+ renderWidgetConfigDialog
515
+ }) {
516
+ const zonesWidthPx = (widgetCount + 1) * ZONE_WIDTH_PX;
517
+ const widthCss = `calc((100% - ${zonesWidthPx}px) * ${item.colSpan} / ${COLS})`;
518
+ const [{ isDragging }, dragRef, previewRef] = useDrag(
519
+ () => ({
520
+ type: ItemTypes.WIDGET,
521
+ item: { widgetId: item.id, fromRowId: rowId },
522
+ canDrag: !readOnly,
523
+ collect: (monitor) => ({ isDragging: monitor.isDragging() })
524
+ }),
525
+ [item.id, rowId, readOnly]
526
+ );
527
+ const [configureOpen, setConfigureOpen] = useState(false);
528
+ return /* @__PURE__ */ jsxs(
529
+ "div",
530
+ {
531
+ ref: (node) => {
532
+ previewRef(node);
533
+ },
534
+ style: { width: widthCss },
535
+ className: cn(
536
+ "group relative h-full min-w-0 shrink-0 overflow-hidden transition-opacity",
537
+ isDragging && "opacity-40"
538
+ ),
539
+ children: [
540
+ /* @__PURE__ */ jsx("div", { className: "h-full", children: widget.content }),
541
+ !readOnly ? /* @__PURE__ */ jsxs(Fragment, { children: [
542
+ /* @__PURE__ */ jsx(
543
+ "span",
544
+ {
545
+ ref: (node) => {
546
+ dragRef(node);
547
+ },
548
+ className: "absolute top-2 left-2 z-10 cursor-grab rounded-md bg-background/80 p-1 text-muted-foreground opacity-0 backdrop-blur transition-opacity group-hover:opacity-100 focus-visible:opacity-100 active:cursor-grabbing",
549
+ title: "Drag to move",
550
+ children: /* @__PURE__ */ jsx(GripVerticalIcon, { className: "size-3.5" })
551
+ }
552
+ ),
553
+ renderWidgetMenu?.(widget, {
554
+ onConfigure: () => setConfigureOpen(true),
555
+ onDelete: () => onRemoveWidget(widget.id, rowId)
556
+ }),
557
+ renderWidgetConfigDialog?.({
558
+ widget,
559
+ open: configureOpen,
560
+ onOpenChange: setConfigureOpen
561
+ })
562
+ ] }) : null
563
+ ]
564
+ }
565
+ );
566
+ }
567
+ function RowResizeHandle({
568
+ rowId,
569
+ heightPx,
570
+ onResize
571
+ }) {
572
+ const handleMouseDown = useCallback(
573
+ (event) => {
574
+ event.preventDefault();
575
+ event.stopPropagation();
576
+ const startY = event.clientY;
577
+ const startHeight = heightPx;
578
+ function handleMove(moveEvent) {
579
+ const nextHeight = Math.min(
580
+ MAX_ROW_HEIGHT,
581
+ Math.max(MIN_ROW_HEIGHT, startHeight + (moveEvent.clientY - startY))
582
+ );
583
+ onResize(rowId, nextHeight);
584
+ }
585
+ function handleUp() {
586
+ window.removeEventListener("mousemove", handleMove);
587
+ window.removeEventListener("mouseup", handleUp);
588
+ }
589
+ window.addEventListener("mousemove", handleMove);
590
+ window.addEventListener("mouseup", handleUp);
591
+ },
592
+ [rowId, heightPx, onResize]
593
+ );
594
+ return /* @__PURE__ */ jsx(
595
+ "div",
596
+ {
597
+ onMouseDown: handleMouseDown,
598
+ className: "absolute inset-x-6 -bottom-2 flex h-3 cursor-ns-resize items-center justify-center",
599
+ title: "Drag to resize row height",
600
+ children: /* @__PURE__ */ jsx("div", { className: "h-1 w-10 rounded-full bg-border" })
601
+ }
602
+ );
603
+ }
604
+ export {
605
+ DashboardGrid,
606
+ EXTERNAL_ITEM_TYPE
607
+ };
608
+ //# sourceMappingURL=dashboard-grid.js.map