@r2digisolutions/components 0.8.0 → 0.8.2
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/dist/components/molecules/Select/Select.svelte +9 -3
- package/dist/components/molecules/Select/Select.svelte.d.ts +2 -0
- package/dist/components/molecules/WidgetFrame/WidgetFrame.svelte +97 -55
- package/dist/components/molecules/WidgetFrame/WidgetFrame.svelte.d.ts +3 -2
- package/dist/components/organisms/DashboardGrid/DashboardGrid.svelte +316 -56
- package/dist/components/organisms/DashboardGrid/DashboardGrid.svelte.d.ts +16 -1
- package/dist/utils/layoutGrid.d.ts +1 -1
- package/dist/utils/layoutGrid.js +19 -17
- package/package.json +1 -1
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export interface SelectOption {
|
|
6
6
|
value: string;
|
|
7
7
|
label: string;
|
|
8
|
+
/** Secondary line under the label (e.g. email). Also included in search. */
|
|
9
|
+
hint?: string;
|
|
8
10
|
disabled?: boolean;
|
|
9
11
|
children?: SelectOption[];
|
|
10
12
|
}
|
|
@@ -139,7 +141,9 @@
|
|
|
139
141
|
const q = searchQuery.toLowerCase();
|
|
140
142
|
return leafOptions.filter(
|
|
141
143
|
(o) =>
|
|
142
|
-
o.label.toLowerCase().includes(q) ||
|
|
144
|
+
o.label.toLowerCase().includes(q) ||
|
|
145
|
+
(o.hint?.toLowerCase().includes(q) ?? false) ||
|
|
146
|
+
(o.breadcrumb?.toLowerCase().includes(q) ?? false)
|
|
143
147
|
);
|
|
144
148
|
}
|
|
145
149
|
return currentLevel.options;
|
|
@@ -587,6 +591,8 @@
|
|
|
587
591
|
{@const isHighlighted = highlightedIndex === index}
|
|
588
592
|
{@const hasChildren = Boolean(option.children?.length) && !isSearching}
|
|
589
593
|
{@const breadcrumb = 'breadcrumb' in option ? option.breadcrumb : undefined}
|
|
594
|
+
{@const hint = option.hint?.trim() || undefined}
|
|
595
|
+
{@const secondary = hint ?? breadcrumb}
|
|
590
596
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
591
597
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
592
598
|
<div
|
|
@@ -641,7 +647,7 @@
|
|
|
641
647
|
<span class={['block truncate', isSelected && 'font-medium']}>
|
|
642
648
|
{option.label}
|
|
643
649
|
</span>
|
|
644
|
-
{#if
|
|
650
|
+
{#if secondary}
|
|
645
651
|
<span
|
|
646
652
|
class={[
|
|
647
653
|
'mt-0.5 block truncate text-[11px]',
|
|
@@ -650,7 +656,7 @@
|
|
|
650
656
|
: 'text-secondary'
|
|
651
657
|
]}
|
|
652
658
|
>
|
|
653
|
-
{
|
|
659
|
+
{secondary}
|
|
654
660
|
</span>
|
|
655
661
|
{/if}
|
|
656
662
|
</span>
|
|
@@ -49,11 +49,12 @@
|
|
|
49
49
|
showChrome?: boolean;
|
|
50
50
|
/** Remove body padding so media/text fill the frame. */
|
|
51
51
|
flush?: boolean;
|
|
52
|
-
/**
|
|
53
|
-
* Selection chrome for canva: true = selected (border on; handles on hover),
|
|
52
|
+
/** Selection chrome for canva: true = selected (border on; handles on hover),
|
|
54
53
|
* false = hide handles. Default: handles on hover.
|
|
55
54
|
*/
|
|
56
55
|
handlesVisible?: boolean;
|
|
56
|
+
/** Limit which resize edges are shown (grid mode). Default: all edges. */
|
|
57
|
+
resizeEdges?: WidgetResizeEdge[];
|
|
57
58
|
/**
|
|
58
59
|
* Raise z-index while selected (`handlesVisible`). Canvas layers should
|
|
59
60
|
* set `false` so stacking order stays truthful; drag still raises via `mode`.
|
|
@@ -106,6 +107,7 @@
|
|
|
106
107
|
showChrome = true,
|
|
107
108
|
flush = false,
|
|
108
109
|
handlesVisible,
|
|
110
|
+
resizeEdges,
|
|
109
111
|
raiseOnSelect = true,
|
|
110
112
|
stackIndex,
|
|
111
113
|
transform: frameTransform,
|
|
@@ -151,7 +153,14 @@
|
|
|
151
153
|
/** Tamaño local cuando resizable sin freeform (EditableChrome, etc.) */
|
|
152
154
|
let localW = $state<number | null>(null);
|
|
153
155
|
let localH = $state<number | null>(null);
|
|
154
|
-
let rootEl
|
|
156
|
+
let rootEl: HTMLDivElement | null = null;
|
|
157
|
+
|
|
158
|
+
function attachRoot(node: HTMLElement) {
|
|
159
|
+
rootEl = node as HTMLDivElement;
|
|
160
|
+
return () => {
|
|
161
|
+
if (rootEl === node) rootEl = null;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
155
164
|
|
|
156
165
|
const showDragHandle = $derived(showChrome && (editable || freeform) && draggable);
|
|
157
166
|
const showResizeHandle = $derived((editable || freeform) && resizable && !collapsed);
|
|
@@ -379,28 +388,54 @@
|
|
|
379
388
|
];
|
|
380
389
|
|
|
381
390
|
const freeformEdgesCanva: { edge: WidgetResizeEdge; class: string }[] = [
|
|
382
|
-
{
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
{
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
391
|
+
{
|
|
392
|
+
edge: 'n',
|
|
393
|
+
class: 'left-1/2 top-0 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-n-resize'
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
edge: 's',
|
|
397
|
+
class: 'left-1/2 bottom-0 h-3 w-3 -translate-x-1/2 translate-y-1/2 cursor-s-resize'
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
edge: 'e',
|
|
401
|
+
class: 'top-1/2 right-0 h-3 w-3 translate-x-1/2 -translate-y-1/2 cursor-e-resize'
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
edge: 'w',
|
|
405
|
+
class: 'top-1/2 left-0 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-w-resize'
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
edge: 'nw',
|
|
409
|
+
class: 'left-0 top-0 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize'
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
edge: 'ne',
|
|
413
|
+
class: 'right-0 top-0 h-3 w-3 translate-x-1/2 -translate-y-1/2 cursor-ne-resize'
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
edge: 'sw',
|
|
417
|
+
class: 'bottom-0 left-0 h-3 w-3 -translate-x-1/2 translate-y-1/2 cursor-sw-resize'
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
edge: 'se',
|
|
421
|
+
class: 'bottom-0 right-0 h-3 w-3 translate-x-1/2 translate-y-1/2 cursor-se-resize'
|
|
422
|
+
}
|
|
390
423
|
];
|
|
391
424
|
|
|
392
425
|
const freeformEdges = $derived(isCanva ? freeformEdgesCanva : freeformEdgesDefault);
|
|
426
|
+
const visibleResizeEdges = $derived.by(() => {
|
|
427
|
+
const allowed = resizeEdges ?? freeformEdges.map((h) => h.edge);
|
|
428
|
+
const set = new Set(allowed);
|
|
429
|
+
return freeformEdges.filter((h) => set.has(h.edge));
|
|
430
|
+
});
|
|
393
431
|
|
|
394
432
|
const rootStyle = $derived.by(() => {
|
|
395
433
|
if (freeform) {
|
|
396
|
-
const xf = frameTransform
|
|
397
|
-
? `transform:${frameTransform};transform-origin:0 0;`
|
|
398
|
-
: '';
|
|
434
|
+
const xf = frameTransform ? `transform:${frameTransform};transform-origin:0 0;` : '';
|
|
399
435
|
return `left:${rect.x}px;top:${rect.y}px;width:${rect.w}px;height:${collapsed ? 'auto' : `${rect.h}px`};z-index:${paintZ};${xf}`;
|
|
400
436
|
}
|
|
401
437
|
if (localW != null) {
|
|
402
|
-
const h =
|
|
403
|
-
collapsed || localH == null ? 'auto' : `${localH}px`;
|
|
438
|
+
const h = collapsed || localH == null ? 'auto' : `${localH}px`;
|
|
404
439
|
return `width:${localW}px;height:${h};`;
|
|
405
440
|
}
|
|
406
441
|
return undefined;
|
|
@@ -427,7 +462,7 @@
|
|
|
427
462
|
return [
|
|
428
463
|
'absolute z-10 touch-none rounded-sm bg-transparent transition-colors',
|
|
429
464
|
'hover:bg-brand-500/70 focus-visible:bg-brand-500/70 focus-visible:outline-none',
|
|
430
|
-
mode === 'resize'
|
|
465
|
+
mode === 'resize' || handlesVisible === true
|
|
431
466
|
? 'pointer-events-auto bg-brand-500/40'
|
|
432
467
|
: 'pointer-events-none group-hover/widget:pointer-events-auto group-hover/widget:bg-brand-500/35'
|
|
433
468
|
].join(' ');
|
|
@@ -436,23 +471,34 @@
|
|
|
436
471
|
const canvaBorder = $derived(
|
|
437
472
|
isCanva && (handlesVisible === true || mode === 'resize' || mode === 'move')
|
|
438
473
|
);
|
|
474
|
+
|
|
475
|
+
/** `h-full` from parents must not win when collapsed — otherwise the card shell fills the grid cell empty. */
|
|
476
|
+
const frameClassName = $derived.by(() => {
|
|
477
|
+
if (!collapsed) return className;
|
|
478
|
+
const base = className
|
|
479
|
+
? String(className)
|
|
480
|
+
.split(/\s+/)
|
|
481
|
+
.filter((token) => token && token !== 'h-full' && token !== 'min-h-0')
|
|
482
|
+
: [];
|
|
483
|
+
return [...base, 'h-auto', 'max-h-fit', 'self-start', 'shrink-0'].join(' ');
|
|
484
|
+
});
|
|
439
485
|
</script>
|
|
440
486
|
|
|
441
487
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
442
488
|
<div
|
|
443
|
-
|
|
489
|
+
{@attach attachRoot}
|
|
444
490
|
class={[
|
|
445
|
-
'group/widget
|
|
446
|
-
!isCanva && '
|
|
491
|
+
'group/widget min-h-0 min-w-0 flex flex-col overflow-visible',
|
|
492
|
+
!isCanva && showChrome && 'rounded-xl border-border bg-surface-elevated overflow-hidden border',
|
|
493
|
+
!isCanva && !showChrome && 'bg-transparent',
|
|
447
494
|
isCanva && 'bg-transparent',
|
|
448
495
|
canvaBorder && 'outline outline-2 outline-[#3b82f6]',
|
|
449
|
-
freeform ? '
|
|
496
|
+
freeform ? 'shadow-md absolute' : 'relative',
|
|
450
497
|
isCanva && 'shadow-none',
|
|
451
|
-
collapsed && 'h-auto self-start',
|
|
452
498
|
mode === 'move' && 'cursor-grabbing opacity-95',
|
|
453
|
-
mode === 'resize' && !isCanva && 'ring-
|
|
499
|
+
mode === 'resize' && !isCanva && 'ring-brand-500/40 ring-1',
|
|
454
500
|
!showChrome && freeform && draggable && 'cursor-move',
|
|
455
|
-
|
|
501
|
+
frameClassName
|
|
456
502
|
]}
|
|
457
503
|
style={rootStyle}
|
|
458
504
|
onpointerdown={(e) => {
|
|
@@ -470,16 +516,14 @@
|
|
|
470
516
|
{#if showHeader}
|
|
471
517
|
<div
|
|
472
518
|
class={[
|
|
473
|
-
'
|
|
519
|
+
'gap-2 border-border px-3 py-2.5 flex shrink-0 items-center border-b',
|
|
474
520
|
showDragHandle && 'touch-none',
|
|
475
521
|
collapsed && 'border-b-0'
|
|
476
522
|
]}
|
|
477
523
|
onpointerdown={(e) => {
|
|
478
|
-
|
|
479
|
-
// Excluimos botones para no interferir con reload/collapse/remove.
|
|
480
|
-
if (!freeform || !showDragHandle) return;
|
|
524
|
+
if (!showDragHandle) return;
|
|
481
525
|
const target = e.target as HTMLElement;
|
|
482
|
-
if (target?.closest('button')) return;
|
|
526
|
+
if (target?.closest('button, a, [data-resize-handle]')) return;
|
|
483
527
|
beginMove(e);
|
|
484
528
|
}}
|
|
485
529
|
>
|
|
@@ -492,23 +536,20 @@
|
|
|
492
536
|
beginMove(e);
|
|
493
537
|
}}
|
|
494
538
|
>
|
|
495
|
-
<DragHandle
|
|
496
|
-
size={freeform ? 'md' : 'sm'}
|
|
497
|
-
label={`Drag ${title ?? 'widget'}`}
|
|
498
|
-
/>
|
|
539
|
+
<DragHandle size={freeform ? 'md' : 'sm'} label={`Drag ${title ?? 'widget'}`} />
|
|
499
540
|
</div>
|
|
500
541
|
{/if}
|
|
501
542
|
|
|
502
543
|
<div class="min-w-0 flex-1">
|
|
503
544
|
{#if title}
|
|
504
|
-
<h3 class="
|
|
545
|
+
<h3 class="text-sm font-semibold text-primary truncate leading-none">{title}</h3>
|
|
505
546
|
{/if}
|
|
506
547
|
{#if description}
|
|
507
|
-
<p class="mt-0.5 truncate text-[11px] leading-none
|
|
548
|
+
<p class="mt-0.5 text-muted truncate text-[11px] leading-none">{description}</p>
|
|
508
549
|
{/if}
|
|
509
550
|
</div>
|
|
510
551
|
|
|
511
|
-
<div class="flex shrink-0 items-center
|
|
552
|
+
<div class="gap-0.5 flex shrink-0 items-center">
|
|
512
553
|
{#if actions}
|
|
513
554
|
{@render actions()}
|
|
514
555
|
{/if}
|
|
@@ -521,10 +562,7 @@
|
|
|
521
562
|
disabled={busy}
|
|
522
563
|
onclick={() => handleReload()}
|
|
523
564
|
>
|
|
524
|
-
<RefreshCw
|
|
525
|
-
class={['h-3.5 w-3.5', busy && 'animate-spin']}
|
|
526
|
-
aria-hidden="true"
|
|
527
|
-
/>
|
|
565
|
+
<RefreshCw class={['h-3.5 w-3.5', busy && 'animate-spin']} aria-hidden="true" />
|
|
528
566
|
</button>
|
|
529
567
|
{/if}
|
|
530
568
|
|
|
@@ -552,7 +590,11 @@
|
|
|
552
590
|
type="button"
|
|
553
591
|
class={[btnClass, 'hover:bg-red-500/10 hover:text-red-600']}
|
|
554
592
|
aria-label={`Remove ${title ?? 'widget'}`}
|
|
555
|
-
|
|
593
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
594
|
+
onclick={(e) => {
|
|
595
|
+
e.stopPropagation();
|
|
596
|
+
onremove?.();
|
|
597
|
+
}}
|
|
556
598
|
>
|
|
557
599
|
<X class="h-3.5 w-3.5" aria-hidden="true" />
|
|
558
600
|
</button>
|
|
@@ -562,16 +604,11 @@
|
|
|
562
604
|
{/if}
|
|
563
605
|
|
|
564
606
|
{#if !collapsed}
|
|
565
|
-
<div
|
|
566
|
-
class={[
|
|
567
|
-
'relative min-h-0 flex-1',
|
|
568
|
-
flush ? 'overflow-hidden p-0' : 'overflow-auto p-3'
|
|
569
|
-
]}
|
|
570
|
-
>
|
|
607
|
+
<div class={['min-h-0 relative flex-1', flush ? 'p-0 overflow-hidden' : 'p-3 overflow-auto']}>
|
|
571
608
|
{#if busy}
|
|
572
609
|
{#if loadingMode === 'spinner'}
|
|
573
610
|
<div
|
|
574
|
-
class="
|
|
611
|
+
class="min-h-32 gap-2 flex flex-col items-center justify-center"
|
|
575
612
|
aria-busy="true"
|
|
576
613
|
aria-live="polite"
|
|
577
614
|
>
|
|
@@ -588,10 +625,10 @@
|
|
|
588
625
|
{/if}
|
|
589
626
|
{:else if empty}
|
|
590
627
|
<div
|
|
591
|
-
class="
|
|
628
|
+
class="min-h-32 rounded-lg border-border bg-surface/50 px-4 py-6 flex h-full flex-col items-center justify-center border border-dashed text-center"
|
|
592
629
|
>
|
|
593
630
|
<div
|
|
594
|
-
class="mb-3
|
|
631
|
+
class="mb-3 h-10 w-10 rounded-xl bg-brand-50 text-brand-600 dark:bg-brand-950/50 dark:text-brand-400 flex items-center justify-center"
|
|
595
632
|
>
|
|
596
633
|
<svg
|
|
597
634
|
viewBox="0 0 24 24"
|
|
@@ -614,8 +651,7 @@
|
|
|
614
651
|
{/if}
|
|
615
652
|
|
|
616
653
|
{#if showResizeHandle}
|
|
617
|
-
{#each
|
|
618
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
654
|
+
{#each visibleResizeEdges as h (h.edge)}
|
|
619
655
|
<div
|
|
620
656
|
data-resize-handle
|
|
621
657
|
class={[edgeAccent, h.class]}
|
|
@@ -630,12 +666,18 @@
|
|
|
630
666
|
<!-- SE grip (visual only, small) -->
|
|
631
667
|
<div
|
|
632
668
|
class={[
|
|
633
|
-
'
|
|
634
|
-
'text-muted/60
|
|
669
|
+
'bottom-0.5 right-0.5 h-2.5 w-2.5 pointer-events-none absolute z-20 flex items-center justify-center',
|
|
670
|
+
'text-muted/60 group-hover/widget:text-brand-500 transition-colors'
|
|
635
671
|
]}
|
|
636
672
|
aria-hidden="true"
|
|
637
673
|
>
|
|
638
|
-
<svg
|
|
674
|
+
<svg
|
|
675
|
+
viewBox="0 0 12 12"
|
|
676
|
+
fill="none"
|
|
677
|
+
stroke="currentColor"
|
|
678
|
+
stroke-width="1.75"
|
|
679
|
+
class="h-2.5 w-2.5"
|
|
680
|
+
>
|
|
639
681
|
<path stroke-linecap="round" d="M7 11L11 7M4 11L11 4" />
|
|
640
682
|
</svg>
|
|
641
683
|
</div>
|
|
@@ -33,11 +33,12 @@ interface WidgetFrameProps {
|
|
|
33
33
|
showChrome?: boolean;
|
|
34
34
|
/** Remove body padding so media/text fill the frame. */
|
|
35
35
|
flush?: boolean;
|
|
36
|
-
/**
|
|
37
|
-
* Selection chrome for canva: true = selected (border on; handles on hover),
|
|
36
|
+
/** Selection chrome for canva: true = selected (border on; handles on hover),
|
|
38
37
|
* false = hide handles. Default: handles on hover.
|
|
39
38
|
*/
|
|
40
39
|
handlesVisible?: boolean;
|
|
40
|
+
/** Limit which resize edges are shown (grid mode). Default: all edges. */
|
|
41
|
+
resizeEdges?: WidgetResizeEdge[];
|
|
41
42
|
/**
|
|
42
43
|
* Raise z-index while selected (`handlesVisible`). Canvas layers should
|
|
43
44
|
* set `false` so stacking order stays truthful; drag still raises via `mode`.
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type
|
|
2
|
+
import { onDestroy, type Snippet } from 'svelte';
|
|
3
|
+
import Pencil from '@lucide/svelte/icons/pencil';
|
|
4
|
+
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
3
5
|
import WidgetFrame from '../../molecules/WidgetFrame/WidgetFrame.svelte';
|
|
4
6
|
import type { WidgetResizeEdge } from '../../molecules/WidgetFrame/WidgetFrame.svelte';
|
|
5
7
|
import DashboardGridToolbar from '../../molecules/DashboardGridToolbar/DashboardGridToolbar.svelte';
|
|
6
8
|
import EmptyState from '../../molecules/EmptyState/EmptyState.svelte';
|
|
7
9
|
import Button from '../../atoms/Button/Button.svelte';
|
|
8
10
|
import Badge from '../../atoms/Badge/Badge.svelte';
|
|
11
|
+
import IconButton from '../../atoms/IconButton/IconButton.svelte';
|
|
9
12
|
import {
|
|
10
13
|
type GridItem,
|
|
11
14
|
type GridDensity,
|
|
@@ -31,6 +34,14 @@
|
|
|
31
34
|
type?: string;
|
|
32
35
|
loading?: boolean;
|
|
33
36
|
empty?: boolean;
|
|
37
|
+
/** When false, no card border/header (frameless body). Default true. */
|
|
38
|
+
showChrome?: boolean;
|
|
39
|
+
/** Remove body padding (often with frameless). */
|
|
40
|
+
flush?: boolean;
|
|
41
|
+
/** Show collapse control in header. Ignored when showChrome is false. */
|
|
42
|
+
collapsible?: boolean;
|
|
43
|
+
/** Called when the user clicks reload in the header. */
|
|
44
|
+
onReload?: () => void | Promise<void>;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
export interface DashboardGridSettings {
|
|
@@ -51,6 +62,8 @@
|
|
|
51
62
|
rowHeight?: number;
|
|
52
63
|
gap?: number;
|
|
53
64
|
editable?: boolean;
|
|
65
|
+
/** Allow drag-to-move (off = use config panel / toolbar instead) */
|
|
66
|
+
draggable?: boolean;
|
|
54
67
|
compact?: boolean;
|
|
55
68
|
/** Draw dashed cell guides */
|
|
56
69
|
showGrid?: boolean;
|
|
@@ -68,9 +81,14 @@
|
|
|
68
81
|
render?: Snippet<[DashboardWidgetMeta, GridItem]>;
|
|
69
82
|
onchange?: (layout: GridItem[]) => void;
|
|
70
83
|
onsettingschange?: (settings: DashboardGridSettings) => void;
|
|
84
|
+
onselect?: (id: string) => void;
|
|
85
|
+
onedit?: (id: string) => void;
|
|
86
|
+
selectedId?: string | null;
|
|
71
87
|
onremove?: (id: string) => void;
|
|
72
88
|
onadd?: () => void;
|
|
73
89
|
onreset?: () => void;
|
|
90
|
+
/** Label shown in the drop placeholder while dragging */
|
|
91
|
+
dropLabel?: string;
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
let {
|
|
@@ -80,6 +98,7 @@
|
|
|
80
98
|
rowHeight = $bindable(DEFAULT_ROW_HEIGHT),
|
|
81
99
|
gap = $bindable(DEFAULT_GAP),
|
|
82
100
|
editable = $bindable(false),
|
|
101
|
+
draggable = true,
|
|
83
102
|
compact = $bindable(true),
|
|
84
103
|
showGrid = $bindable(true),
|
|
85
104
|
showToolbar = false,
|
|
@@ -93,30 +112,65 @@
|
|
|
93
112
|
render,
|
|
94
113
|
onchange,
|
|
95
114
|
onsettingschange,
|
|
115
|
+
onselect,
|
|
116
|
+
onedit,
|
|
117
|
+
selectedId = null,
|
|
96
118
|
onremove,
|
|
97
119
|
onadd,
|
|
98
|
-
onreset
|
|
120
|
+
onreset,
|
|
121
|
+
dropLabel = 'Drop here'
|
|
99
122
|
}: DashboardGridProps = $props();
|
|
100
123
|
|
|
101
|
-
|
|
124
|
+
const DRAG_THRESHOLD = 6;
|
|
125
|
+
|
|
126
|
+
let containerEl: HTMLDivElement | null = null;
|
|
127
|
+
let ghostEl: HTMLDivElement | null = null;
|
|
102
128
|
let draggingId = $state<string | null>(null);
|
|
103
129
|
let resizingId = $state<string | null>(null);
|
|
104
130
|
let resizeEdge = $state<WidgetResizeEdge>('se');
|
|
105
131
|
let origin = $state({ x: 0, y: 0, itemX: 0, itemY: 0, itemW: 0, itemH: 0 });
|
|
132
|
+
let dragMoved = $state(false);
|
|
133
|
+
let listening = $state(false);
|
|
134
|
+
let snapX = $state(0);
|
|
135
|
+
let snapY = $state(0);
|
|
136
|
+
let ghostBox = $state<{
|
|
137
|
+
left: number;
|
|
138
|
+
top: number;
|
|
139
|
+
width: number;
|
|
140
|
+
height: number;
|
|
141
|
+
title: string;
|
|
142
|
+
} | null>(null);
|
|
143
|
+
let lastDx = 0;
|
|
144
|
+
let lastDy = 0;
|
|
106
145
|
|
|
107
|
-
const metaById = $derived.by(() => {
|
|
108
|
-
const map
|
|
109
|
-
for (const w of widgets) map
|
|
146
|
+
const metaById = $derived.by((): Record<string, DashboardWidgetMeta> => {
|
|
147
|
+
const map: Record<string, DashboardWidgetMeta> = {};
|
|
148
|
+
for (const w of widgets) map[w.id] = w;
|
|
110
149
|
for (const it of layout) {
|
|
111
|
-
if (!map
|
|
150
|
+
if (!map[it.id]) map[it.id] = { id: it.id, title: it.id };
|
|
112
151
|
}
|
|
113
152
|
return map;
|
|
114
153
|
});
|
|
115
154
|
|
|
155
|
+
function widgetMeta(item: GridItem): DashboardWidgetMeta {
|
|
156
|
+
return metaById[item.id] ?? { id: item.id, title: item.id };
|
|
157
|
+
}
|
|
158
|
+
|
|
116
159
|
const contentRows = $derived(layoutBounds(layout).rows);
|
|
117
|
-
const rows = $derived(
|
|
118
|
-
|
|
119
|
-
)
|
|
160
|
+
const rows = $derived(Math.max(contentRows + (editable ? padRows : 0), minRows));
|
|
161
|
+
const draggingItem = $derived(layout.find((it) => it.id === draggingId) ?? null);
|
|
162
|
+
const snapPreview = $derived.by((): GridItem | null => {
|
|
163
|
+
if (!draggingItem || !dragMoved) return null;
|
|
164
|
+
return clampItem({ ...draggingItem, x: snapX, y: snapY }, cols);
|
|
165
|
+
});
|
|
166
|
+
const originPreview = $derived.by((): GridItem | null => {
|
|
167
|
+
if (!draggingItem || !dragMoved) return null;
|
|
168
|
+
if (snapX === origin.itemX && snapY === origin.itemY) return null;
|
|
169
|
+
return clampItem(
|
|
170
|
+
{ ...draggingItem, x: origin.itemX, y: origin.itemY },
|
|
171
|
+
cols
|
|
172
|
+
);
|
|
173
|
+
});
|
|
120
174
|
|
|
121
175
|
function cellSize(): { cw: number; rh: number } {
|
|
122
176
|
const width = containerEl?.clientWidth ?? 0;
|
|
@@ -160,13 +214,107 @@
|
|
|
160
214
|
emitSettings();
|
|
161
215
|
}
|
|
162
216
|
|
|
163
|
-
function
|
|
217
|
+
function attachWindowListeners() {
|
|
218
|
+
if (listening || typeof window === 'undefined') return;
|
|
219
|
+
listening = true;
|
|
220
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
221
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
222
|
+
window.addEventListener('pointercancel', onPointerUp);
|
|
223
|
+
window.addEventListener('keydown', onKeyDown);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function detachWindowListeners() {
|
|
227
|
+
if (!listening || typeof window === 'undefined') return;
|
|
228
|
+
listening = false;
|
|
229
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
230
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
231
|
+
window.removeEventListener('pointercancel', onPointerUp);
|
|
232
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function unlockPointerChrome() {
|
|
236
|
+
if (typeof document === 'undefined') return;
|
|
237
|
+
document.body.style.userSelect = '';
|
|
238
|
+
document.body.style.cursor = '';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function lockPointerChrome() {
|
|
242
|
+
if (typeof document === 'undefined') return;
|
|
243
|
+
document.body.style.userSelect = 'none';
|
|
244
|
+
document.body.style.cursor = 'grabbing';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function mountGhostClone(node: HTMLDivElement) {
|
|
248
|
+
node.replaceChildren();
|
|
249
|
+
const cell = draggingId
|
|
250
|
+
? containerEl?.querySelector(`[data-grid-id="${CSS.escape(draggingId)}"]`)
|
|
251
|
+
: null;
|
|
252
|
+
if (cell instanceof HTMLElement) {
|
|
253
|
+
const clone = cell.cloneNode(true) as HTMLElement;
|
|
254
|
+
clone.querySelector('[data-widget-toolbar]')?.remove();
|
|
255
|
+
clone.style.pointerEvents = 'none';
|
|
256
|
+
clone.classList.add('shadow-none', 'ring-0');
|
|
257
|
+
node.appendChild(clone);
|
|
258
|
+
}
|
|
259
|
+
return () => {
|
|
260
|
+
node.replaceChildren();
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function applyGhostTransform(dx: number, dy: number) {
|
|
265
|
+
lastDx = dx;
|
|
266
|
+
lastDy = dy;
|
|
267
|
+
if (!ghostEl) return;
|
|
268
|
+
ghostEl.style.transform = `translate(${dx}px, ${dy}px) rotate(-1deg) scale(1.02)`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function attachGhost(node: HTMLElement) {
|
|
272
|
+
ghostEl = node as HTMLDivElement;
|
|
273
|
+
applyGhostTransform(lastDx, lastDy);
|
|
274
|
+
return () => {
|
|
275
|
+
if (ghostEl === node) ghostEl = null;
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function attachContainer(node: HTMLElement) {
|
|
280
|
+
containerEl = node as HTMLDivElement;
|
|
281
|
+
return () => {
|
|
282
|
+
if (containerEl === node) containerEl = null;
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function resetInteraction() {
|
|
287
|
+
draggingId = null;
|
|
288
|
+
resizingId = null;
|
|
289
|
+
dragMoved = false;
|
|
290
|
+
ghostBox = null;
|
|
291
|
+
lastDx = 0;
|
|
292
|
+
lastDy = 0;
|
|
293
|
+
unlockPointerChrome();
|
|
294
|
+
detachWindowListeners();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function onCellPointerDown(id: string, e: PointerEvent) {
|
|
164
298
|
if (!editable) return;
|
|
299
|
+
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
300
|
+
const target = e.target as HTMLElement | null;
|
|
301
|
+
if (target?.closest('button, a, [data-resize-handle], [data-widget-toolbar]')) return;
|
|
302
|
+
onselect?.(id);
|
|
303
|
+
if (draggable) onDragStart(id, e);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function onDragStart(id: string, e: PointerEvent) {
|
|
307
|
+
if (!editable || !draggable) return;
|
|
165
308
|
e.preventDefault();
|
|
166
309
|
e.stopPropagation();
|
|
167
310
|
const item = layout.find((it) => it.id === id);
|
|
168
311
|
if (!item || item.static) return;
|
|
312
|
+
onselect?.(id);
|
|
169
313
|
draggingId = id;
|
|
314
|
+
dragMoved = false;
|
|
315
|
+
ghostBox = null;
|
|
316
|
+
snapX = item.x;
|
|
317
|
+
snapY = item.y;
|
|
170
318
|
origin = {
|
|
171
319
|
x: e.clientX,
|
|
172
320
|
y: e.clientY,
|
|
@@ -175,7 +323,21 @@
|
|
|
175
323
|
itemW: item.w,
|
|
176
324
|
itemH: item.h
|
|
177
325
|
};
|
|
178
|
-
(
|
|
326
|
+
attachWindowListeners();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function beginLift(id: string) {
|
|
330
|
+
const cell = containerEl?.querySelector(`[data-grid-id="${CSS.escape(id)}"]`);
|
|
331
|
+
if (!(cell instanceof HTMLElement)) return;
|
|
332
|
+
const r = cell.getBoundingClientRect();
|
|
333
|
+
ghostBox = {
|
|
334
|
+
left: r.left,
|
|
335
|
+
top: r.top,
|
|
336
|
+
width: r.width,
|
|
337
|
+
height: r.height,
|
|
338
|
+
title: metaById[id]?.title ?? id
|
|
339
|
+
};
|
|
340
|
+
lockPointerChrome();
|
|
179
341
|
}
|
|
180
342
|
|
|
181
343
|
function onResizeStart(id: string, e: PointerEvent, edge: WidgetResizeEdge) {
|
|
@@ -184,8 +346,10 @@
|
|
|
184
346
|
e.stopPropagation();
|
|
185
347
|
const item = layout.find((it) => it.id === id);
|
|
186
348
|
if (!item || item.static) return;
|
|
349
|
+
onselect?.(id);
|
|
187
350
|
resizingId = id;
|
|
188
351
|
resizeEdge = edge;
|
|
352
|
+
dragMoved = false;
|
|
189
353
|
origin = {
|
|
190
354
|
x: e.clientX,
|
|
191
355
|
y: e.clientY,
|
|
@@ -195,29 +359,43 @@
|
|
|
195
359
|
itemH: item.h
|
|
196
360
|
};
|
|
197
361
|
(e.target as HTMLElement)?.setPointerCapture?.(e.pointerId);
|
|
362
|
+
attachWindowListeners();
|
|
198
363
|
}
|
|
199
364
|
|
|
200
365
|
function onPointerMove(e: PointerEvent) {
|
|
201
|
-
const { cw, rh } = cellSize();
|
|
202
|
-
if (cw <= 0) return;
|
|
203
|
-
|
|
204
366
|
if (draggingId) {
|
|
205
|
-
const dx =
|
|
206
|
-
const dy =
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
367
|
+
const dx = e.clientX - origin.x;
|
|
368
|
+
const dy = e.clientY - origin.y;
|
|
369
|
+
if (!dragMoved) {
|
|
370
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
371
|
+
dragMoved = true;
|
|
372
|
+
beginLift(draggingId);
|
|
373
|
+
}
|
|
374
|
+
applyGhostTransform(dx, dy);
|
|
375
|
+
const { cw, rh } = cellSize();
|
|
376
|
+
if (cw <= 0) return;
|
|
377
|
+
const colDx = Math.round(dx / (cw + gap));
|
|
378
|
+
const rowDy = Math.round(dy / (rh + gap));
|
|
379
|
+
const item = layout.find((it) => it.id === draggingId);
|
|
380
|
+
if (!item) return;
|
|
381
|
+
const snapped = clampItem(
|
|
382
|
+
{ ...item, x: origin.itemX + colDx, y: origin.itemY + rowDy },
|
|
383
|
+
cols
|
|
214
384
|
);
|
|
385
|
+
if (snapped.x !== snapX || snapped.y !== snapY) {
|
|
386
|
+
snapX = snapped.x;
|
|
387
|
+
snapY = snapped.y;
|
|
388
|
+
}
|
|
215
389
|
return;
|
|
216
390
|
}
|
|
217
391
|
|
|
392
|
+
const { cw, rh } = cellSize();
|
|
393
|
+
if (cw <= 0) return;
|
|
394
|
+
|
|
218
395
|
if (resizingId) {
|
|
219
396
|
const dx = Math.round((e.clientX - origin.x) / (cw + gap));
|
|
220
397
|
const dy = Math.round((e.clientY - origin.y) / (rh + gap));
|
|
398
|
+
if (dx !== 0 || dy !== 0) dragMoved = true;
|
|
221
399
|
const current = layout.find((it) => it.id === resizingId);
|
|
222
400
|
if (!current) return;
|
|
223
401
|
const next = resizeItemByEdge(
|
|
@@ -236,18 +414,29 @@
|
|
|
236
414
|
dy,
|
|
237
415
|
cols
|
|
238
416
|
);
|
|
239
|
-
// No compact while resizing — west/north must keep the anchored edge stable
|
|
240
417
|
emit(updateItem(layout, resizingId, next, { cols, compact: false }));
|
|
241
418
|
}
|
|
242
419
|
}
|
|
243
420
|
|
|
244
421
|
function onPointerUp() {
|
|
422
|
+
const id = draggingId;
|
|
423
|
+
const moved = dragMoved;
|
|
245
424
|
const wasResizing = !!resizingId;
|
|
246
|
-
|
|
247
|
-
|
|
425
|
+
if (id && moved) {
|
|
426
|
+
emit(updateItem(layout, id, { x: snapX, y: snapY }, { cols, compact }));
|
|
427
|
+
}
|
|
248
428
|
if (wasResizing && compact) {
|
|
249
429
|
emit(compactLayout(layout, cols));
|
|
250
430
|
}
|
|
431
|
+
resetInteraction();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
435
|
+
if (e.key !== 'Escape') return;
|
|
436
|
+
if (draggingId || resizingId) {
|
|
437
|
+
e.preventDefault();
|
|
438
|
+
resetInteraction();
|
|
439
|
+
}
|
|
251
440
|
}
|
|
252
441
|
|
|
253
442
|
function styleFor(item: GridItem): string {
|
|
@@ -257,11 +446,8 @@
|
|
|
257
446
|
}
|
|
258
447
|
|
|
259
448
|
function handleRemove(id: string) {
|
|
260
|
-
if (onremove) {
|
|
261
|
-
onremove(id);
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
449
|
emit(removeItem(layout, id, { cols, compact }));
|
|
450
|
+
onremove?.(id);
|
|
265
451
|
}
|
|
266
452
|
|
|
267
453
|
function handleCompactNow() {
|
|
@@ -273,9 +459,13 @@
|
|
|
273
459
|
);
|
|
274
460
|
|
|
275
461
|
const guideRows = $derived(Math.max(rows, minRows));
|
|
462
|
+
|
|
463
|
+
onDestroy(() => {
|
|
464
|
+
resetInteraction();
|
|
465
|
+
});
|
|
276
466
|
</script>
|
|
277
467
|
|
|
278
|
-
<div class={['
|
|
468
|
+
<div class={['space-y-4 w-full', className]}>
|
|
279
469
|
{#if showToolbar}
|
|
280
470
|
<DashboardGridToolbar
|
|
281
471
|
{cols}
|
|
@@ -286,7 +476,7 @@
|
|
|
286
476
|
{showGrid}
|
|
287
477
|
{density}
|
|
288
478
|
oncompact={handleCompactNow}
|
|
289
|
-
|
|
479
|
+
{onreset}
|
|
290
480
|
onchange={applySettings}
|
|
291
481
|
/>
|
|
292
482
|
{/if}
|
|
@@ -304,15 +494,14 @@
|
|
|
304
494
|
{:else}
|
|
305
495
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
306
496
|
<div
|
|
307
|
-
|
|
497
|
+
{@attach attachContainer}
|
|
308
498
|
class={[
|
|
309
|
-
'relative w-full
|
|
310
|
-
editable
|
|
499
|
+
'rounded-xl relative w-full',
|
|
500
|
+
editable ? 'pt-3 overflow-visible' : 'overflow-hidden',
|
|
501
|
+
editable && showGrid && 'ring-border ring-1',
|
|
502
|
+
editable && 'select-none'
|
|
311
503
|
]}
|
|
312
504
|
style={gridStyle}
|
|
313
|
-
onpointermove={onPointerMove}
|
|
314
|
-
onpointerup={onPointerUp}
|
|
315
|
-
onpointercancel={onPointerUp}
|
|
316
505
|
>
|
|
317
506
|
{#if editable && showGrid}
|
|
318
507
|
{#each Array.from({ length: cols * guideRows }, (_, i) => i) as cell (cell)}
|
|
@@ -320,55 +509,112 @@
|
|
|
320
509
|
{@const cy = Math.floor(cell / cols)}
|
|
321
510
|
{@const cellStyle = `grid-column:${cx + 1};grid-row:${cy + 1};`}
|
|
322
511
|
<div
|
|
323
|
-
class="
|
|
512
|
+
class="rounded-lg border-border/50 bg-surface-overlay/25 pointer-events-none border border-dashed"
|
|
324
513
|
style={cellStyle}
|
|
325
514
|
aria-hidden="true"
|
|
326
515
|
></div>
|
|
327
516
|
{/each}
|
|
328
517
|
{/if}
|
|
329
518
|
|
|
519
|
+
{#if draggable && originPreview}
|
|
520
|
+
<div
|
|
521
|
+
class="pointer-events-none z-[2] rounded-xl border-2 border-dashed border-muted/60 bg-muted/10"
|
|
522
|
+
style={styleFor(originPreview)}
|
|
523
|
+
aria-hidden="true"
|
|
524
|
+
></div>
|
|
525
|
+
{/if}
|
|
526
|
+
|
|
527
|
+
{#if draggable && snapPreview}
|
|
528
|
+
<div
|
|
529
|
+
class="pointer-events-none z-[3] rounded-xl border-2 border-dashed border-brand-500 bg-brand-500/20 shadow-[inset_0_0_0_1px_rgba(var(--color-brand-500),0.25)]"
|
|
530
|
+
style={styleFor(snapPreview)}
|
|
531
|
+
aria-hidden="true"
|
|
532
|
+
>
|
|
533
|
+
<div
|
|
534
|
+
class="flex h-full items-center justify-center rounded-[inherit] bg-brand-500/10 text-xs font-semibold tracking-wide text-brand-600 uppercase"
|
|
535
|
+
>
|
|
536
|
+
{dropLabel}
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
{/if}
|
|
540
|
+
|
|
330
541
|
{#each layout as item (item.id)}
|
|
331
|
-
{@const meta =
|
|
542
|
+
{@const meta = widgetMeta(item)}
|
|
332
543
|
{@const clamped = clampItem(item, cols)}
|
|
544
|
+
{@const selected = selectedId === item.id}
|
|
545
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
333
546
|
<div
|
|
547
|
+
data-grid-id={item.id}
|
|
334
548
|
class={[
|
|
335
|
-
'
|
|
336
|
-
|
|
549
|
+
'min-h-0 min-w-0 relative z-[1] flex flex-col',
|
|
550
|
+
editable && draggable && !item.static && 'cursor-grab',
|
|
551
|
+
(draggingId === item.id || resizingId === item.id) && 'z-20',
|
|
552
|
+
draggingId === item.id && dragMoved && 'pointer-events-none opacity-30',
|
|
553
|
+
selected && !dragMoved && 'ring-brand-500 ring-offset-surface ring-2 ring-offset-2'
|
|
337
554
|
]}
|
|
338
555
|
style={styleFor(clamped)}
|
|
556
|
+
onpointerdown={(e) => onCellPointerDown(item.id, e)}
|
|
339
557
|
>
|
|
340
|
-
{#if editable &&
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
558
|
+
{#if editable && !item.static}
|
|
559
|
+
<div
|
|
560
|
+
data-widget-toolbar
|
|
561
|
+
class="-top-3 right-2 gap-0.5 absolute z-30 flex items-center"
|
|
562
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
563
|
+
>
|
|
564
|
+
{#if showSizeBadge}
|
|
565
|
+
<Badge size="sm" variant="secondary" class="shadow-sm tabular-nums">
|
|
566
|
+
{clamped.w}×{clamped.h}
|
|
567
|
+
</Badge>
|
|
568
|
+
{/if}
|
|
569
|
+
<IconButton
|
|
570
|
+
size="xs"
|
|
571
|
+
variant={selected ? 'primary' : 'secondary'}
|
|
572
|
+
label={`Edit ${meta.title ?? 'widget'}`}
|
|
573
|
+
class="shadow-sm"
|
|
574
|
+
onclick={() => {
|
|
575
|
+
onselect?.(item.id);
|
|
576
|
+
onedit?.(item.id);
|
|
577
|
+
}}
|
|
578
|
+
>
|
|
579
|
+
<Pencil />
|
|
580
|
+
</IconButton>
|
|
581
|
+
<IconButton
|
|
582
|
+
size="xs"
|
|
583
|
+
variant="destructive"
|
|
584
|
+
label={`Remove ${meta.title ?? 'widget'}`}
|
|
585
|
+
class="shadow-sm"
|
|
586
|
+
onclick={() => handleRemove(item.id)}
|
|
587
|
+
>
|
|
588
|
+
<Trash2 />
|
|
589
|
+
</IconButton>
|
|
590
|
+
</div>
|
|
349
591
|
{/if}
|
|
350
592
|
<WidgetFrame
|
|
351
593
|
title={meta.title}
|
|
352
594
|
description={meta.description}
|
|
595
|
+
showChrome={meta.showChrome ?? true}
|
|
596
|
+
flush={meta.flush ?? false}
|
|
597
|
+
collapsible={meta.collapsible ?? false}
|
|
353
598
|
{editable}
|
|
354
|
-
draggable={editable && !item.static}
|
|
599
|
+
draggable={editable && draggable && !item.static}
|
|
355
600
|
resizable={editable && !item.static}
|
|
601
|
+
resizeEdges={editable ? (['s', 'e', 'se'] as const) : undefined}
|
|
356
602
|
loading={meta.loading}
|
|
357
603
|
empty={meta.empty}
|
|
604
|
+
onreload={meta.onReload}
|
|
358
605
|
class="h-full"
|
|
359
606
|
ondragstart={(e) => onDragStart(item.id, e)}
|
|
360
607
|
onresizestart={(e, edge) => onResizeStart(item.id, e, edge)}
|
|
361
|
-
onremove={editable && !item.static ? () => handleRemove(item.id) : undefined}
|
|
362
608
|
>
|
|
363
609
|
{#if render}
|
|
364
610
|
{@render render(meta, clamped)}
|
|
365
611
|
{:else}
|
|
366
|
-
<div class="flex h-full flex-col justify-between
|
|
612
|
+
<div class="gap-2 p-3 flex h-full flex-col justify-between">
|
|
367
613
|
<p class="text-sm text-muted">
|
|
368
614
|
Widget “{meta.type ?? meta.id}”
|
|
369
615
|
</p>
|
|
370
616
|
{#if editable}
|
|
371
|
-
<p class="text-[11px] tabular-nums
|
|
617
|
+
<p class="text-muted text-[11px] tabular-nums">
|
|
372
618
|
col {clamped.x + 1} · row {clamped.y + 1}
|
|
373
619
|
</p>
|
|
374
620
|
{/if}
|
|
@@ -386,3 +632,17 @@
|
|
|
386
632
|
{/if}
|
|
387
633
|
{/if}
|
|
388
634
|
</div>
|
|
635
|
+
|
|
636
|
+
{#if draggable && ghostBox}
|
|
637
|
+
<div
|
|
638
|
+
{@attach attachGhost}
|
|
639
|
+
class="pointer-events-none fixed z-[500]"
|
|
640
|
+
style={`left:${ghostBox.left}px;top:${ghostBox.top}px;width:${ghostBox.width}px;height:${ghostBox.height}px;transform-origin:center;`}
|
|
641
|
+
aria-hidden="true"
|
|
642
|
+
>
|
|
643
|
+
<div
|
|
644
|
+
{@attach mountGhostClone}
|
|
645
|
+
class="h-full w-full overflow-hidden rounded-xl border-2 border-brand-500 bg-surface-elevated shadow-2xl"
|
|
646
|
+
></div>
|
|
647
|
+
</div>
|
|
648
|
+
{/if}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
2
|
import { type GridItem, type GridDensity } from '../../../utils/layoutGrid.js';
|
|
3
3
|
export type DashboardGridDensity = GridDensity;
|
|
4
4
|
export interface DashboardWidgetMeta {
|
|
@@ -8,6 +8,14 @@ export interface DashboardWidgetMeta {
|
|
|
8
8
|
type?: string;
|
|
9
9
|
loading?: boolean;
|
|
10
10
|
empty?: boolean;
|
|
11
|
+
/** When false, no card border/header (frameless body). Default true. */
|
|
12
|
+
showChrome?: boolean;
|
|
13
|
+
/** Remove body padding (often with frameless). */
|
|
14
|
+
flush?: boolean;
|
|
15
|
+
/** Show collapse control in header. Ignored when showChrome is false. */
|
|
16
|
+
collapsible?: boolean;
|
|
17
|
+
/** Called when the user clicks reload in the header. */
|
|
18
|
+
onReload?: () => void | Promise<void>;
|
|
11
19
|
}
|
|
12
20
|
export interface DashboardGridSettings {
|
|
13
21
|
cols: number;
|
|
@@ -26,6 +34,8 @@ interface DashboardGridProps {
|
|
|
26
34
|
rowHeight?: number;
|
|
27
35
|
gap?: number;
|
|
28
36
|
editable?: boolean;
|
|
37
|
+
/** Allow drag-to-move (off = use config panel / toolbar instead) */
|
|
38
|
+
draggable?: boolean;
|
|
29
39
|
compact?: boolean;
|
|
30
40
|
/** Draw dashed cell guides */
|
|
31
41
|
showGrid?: boolean;
|
|
@@ -43,9 +53,14 @@ interface DashboardGridProps {
|
|
|
43
53
|
render?: Snippet<[DashboardWidgetMeta, GridItem]>;
|
|
44
54
|
onchange?: (layout: GridItem[]) => void;
|
|
45
55
|
onsettingschange?: (settings: DashboardGridSettings) => void;
|
|
56
|
+
onselect?: (id: string) => void;
|
|
57
|
+
onedit?: (id: string) => void;
|
|
58
|
+
selectedId?: string | null;
|
|
46
59
|
onremove?: (id: string) => void;
|
|
47
60
|
onadd?: () => void;
|
|
48
61
|
onreset?: () => void;
|
|
62
|
+
/** Label shown in the drop placeholder while dragging */
|
|
63
|
+
dropLabel?: string;
|
|
49
64
|
}
|
|
50
65
|
declare const DashboardGrid: import("svelte").Component<DashboardGridProps, {
|
|
51
66
|
GridItem: typeof GridItem;
|
|
@@ -24,7 +24,7 @@ export declare const DEFAULT_GAP = 12;
|
|
|
24
24
|
export declare function clampItem(item: GridItem, cols?: number): GridItem;
|
|
25
25
|
export declare function rectsOverlap(a: GridItem, b: GridItem): boolean;
|
|
26
26
|
export declare function findCollisions(layout: GridItem[], item: GridItem): GridItem[];
|
|
27
|
-
/** Push colliding items down (simple collision resolution). */
|
|
27
|
+
/** Push colliding items down (simple collision resolution). The moved item keeps its target slot. */
|
|
28
28
|
export declare function resolveCollisions(layout: GridItem[], moved: GridItem, cols?: number): GridItem[];
|
|
29
29
|
/** Compact items upward to remove vertical gaps. */
|
|
30
30
|
export declare function compactLayout(layout: GridItem[], cols?: number): GridItem[];
|
package/dist/utils/layoutGrid.js
CHANGED
|
@@ -24,29 +24,31 @@ export function rectsOverlap(a, b) {
|
|
|
24
24
|
export function findCollisions(layout, item) {
|
|
25
25
|
return layout.filter((other) => other.id !== item.id && rectsOverlap(item, other));
|
|
26
26
|
}
|
|
27
|
-
/** Push colliding items down (simple collision resolution). */
|
|
27
|
+
/** Push colliding items down (simple collision resolution). The moved item keeps its target slot. */
|
|
28
28
|
export function resolveCollisions(layout, moved, cols = DEFAULT_COLS) {
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
29
|
+
const target = clampItem(moved, cols);
|
|
30
|
+
const others = layout
|
|
31
|
+
.filter((it) => it.id !== target.id)
|
|
32
|
+
.map((it) => clampItem(it, cols))
|
|
33
|
+
.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
34
|
+
const placed = [];
|
|
35
|
+
const pushUntilClear = (item) => {
|
|
36
|
+
if (item.static)
|
|
37
|
+
return item;
|
|
38
|
+
let current = item;
|
|
41
39
|
let safety = 0;
|
|
42
|
-
while (
|
|
43
|
-
const blocker =
|
|
40
|
+
while (placed.some((other) => rectsOverlap(current, other)) && safety < 200) {
|
|
41
|
+
const blocker = placed.find((other) => rectsOverlap(current, other));
|
|
44
42
|
current = { ...current, y: blocker.y + blocker.h };
|
|
45
43
|
safety++;
|
|
46
44
|
}
|
|
47
|
-
|
|
45
|
+
return current;
|
|
46
|
+
};
|
|
47
|
+
placed.push(target.static ? target : pushUntilClear(target));
|
|
48
|
+
for (const item of others) {
|
|
49
|
+
placed.push(item.static ? item : pushUntilClear(item));
|
|
48
50
|
}
|
|
49
|
-
return
|
|
51
|
+
return placed;
|
|
50
52
|
}
|
|
51
53
|
/** Compact items upward to remove vertical gaps. */
|
|
52
54
|
export function compactLayout(layout, cols = DEFAULT_COLS) {
|