@r2digisolutions/components 0.8.0 → 0.8.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.
- 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 +85 -53
- 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(' ');
|
|
@@ -440,17 +475,18 @@
|
|
|
440
475
|
|
|
441
476
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
442
477
|
<div
|
|
443
|
-
|
|
478
|
+
{@attach attachRoot}
|
|
444
479
|
class={[
|
|
445
|
-
'group/widget
|
|
446
|
-
!isCanva && '
|
|
480
|
+
'group/widget min-h-0 min-w-0 flex flex-col overflow-visible',
|
|
481
|
+
!isCanva && showChrome && 'rounded-xl border-border bg-surface-elevated overflow-hidden border',
|
|
482
|
+
!isCanva && !showChrome && 'bg-transparent',
|
|
447
483
|
isCanva && 'bg-transparent',
|
|
448
484
|
canvaBorder && 'outline outline-2 outline-[#3b82f6]',
|
|
449
|
-
freeform ? '
|
|
485
|
+
freeform ? 'shadow-md absolute' : 'relative',
|
|
450
486
|
isCanva && 'shadow-none',
|
|
451
487
|
collapsed && 'h-auto self-start',
|
|
452
488
|
mode === 'move' && 'cursor-grabbing opacity-95',
|
|
453
|
-
mode === 'resize' && !isCanva && 'ring-
|
|
489
|
+
mode === 'resize' && !isCanva && 'ring-brand-500/40 ring-1',
|
|
454
490
|
!showChrome && freeform && draggable && 'cursor-move',
|
|
455
491
|
className
|
|
456
492
|
]}
|
|
@@ -470,16 +506,14 @@
|
|
|
470
506
|
{#if showHeader}
|
|
471
507
|
<div
|
|
472
508
|
class={[
|
|
473
|
-
'
|
|
509
|
+
'gap-2 border-border px-3 py-2.5 flex shrink-0 items-center border-b',
|
|
474
510
|
showDragHandle && 'touch-none',
|
|
475
511
|
collapsed && 'border-b-0'
|
|
476
512
|
]}
|
|
477
513
|
onpointerdown={(e) => {
|
|
478
|
-
|
|
479
|
-
// Excluimos botones para no interferir con reload/collapse/remove.
|
|
480
|
-
if (!freeform || !showDragHandle) return;
|
|
514
|
+
if (!showDragHandle) return;
|
|
481
515
|
const target = e.target as HTMLElement;
|
|
482
|
-
if (target?.closest('button')) return;
|
|
516
|
+
if (target?.closest('button, a, [data-resize-handle]')) return;
|
|
483
517
|
beginMove(e);
|
|
484
518
|
}}
|
|
485
519
|
>
|
|
@@ -492,23 +526,20 @@
|
|
|
492
526
|
beginMove(e);
|
|
493
527
|
}}
|
|
494
528
|
>
|
|
495
|
-
<DragHandle
|
|
496
|
-
size={freeform ? 'md' : 'sm'}
|
|
497
|
-
label={`Drag ${title ?? 'widget'}`}
|
|
498
|
-
/>
|
|
529
|
+
<DragHandle size={freeform ? 'md' : 'sm'} label={`Drag ${title ?? 'widget'}`} />
|
|
499
530
|
</div>
|
|
500
531
|
{/if}
|
|
501
532
|
|
|
502
533
|
<div class="min-w-0 flex-1">
|
|
503
534
|
{#if title}
|
|
504
|
-
<h3 class="
|
|
535
|
+
<h3 class="text-sm font-semibold text-primary truncate leading-none">{title}</h3>
|
|
505
536
|
{/if}
|
|
506
537
|
{#if description}
|
|
507
|
-
<p class="mt-0.5 truncate text-[11px] leading-none
|
|
538
|
+
<p class="mt-0.5 text-muted truncate text-[11px] leading-none">{description}</p>
|
|
508
539
|
{/if}
|
|
509
540
|
</div>
|
|
510
541
|
|
|
511
|
-
<div class="flex shrink-0 items-center
|
|
542
|
+
<div class="gap-0.5 flex shrink-0 items-center">
|
|
512
543
|
{#if actions}
|
|
513
544
|
{@render actions()}
|
|
514
545
|
{/if}
|
|
@@ -521,10 +552,7 @@
|
|
|
521
552
|
disabled={busy}
|
|
522
553
|
onclick={() => handleReload()}
|
|
523
554
|
>
|
|
524
|
-
<RefreshCw
|
|
525
|
-
class={['h-3.5 w-3.5', busy && 'animate-spin']}
|
|
526
|
-
aria-hidden="true"
|
|
527
|
-
/>
|
|
555
|
+
<RefreshCw class={['h-3.5 w-3.5', busy && 'animate-spin']} aria-hidden="true" />
|
|
528
556
|
</button>
|
|
529
557
|
{/if}
|
|
530
558
|
|
|
@@ -552,7 +580,11 @@
|
|
|
552
580
|
type="button"
|
|
553
581
|
class={[btnClass, 'hover:bg-red-500/10 hover:text-red-600']}
|
|
554
582
|
aria-label={`Remove ${title ?? 'widget'}`}
|
|
555
|
-
|
|
583
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
584
|
+
onclick={(e) => {
|
|
585
|
+
e.stopPropagation();
|
|
586
|
+
onremove?.();
|
|
587
|
+
}}
|
|
556
588
|
>
|
|
557
589
|
<X class="h-3.5 w-3.5" aria-hidden="true" />
|
|
558
590
|
</button>
|
|
@@ -562,16 +594,11 @@
|
|
|
562
594
|
{/if}
|
|
563
595
|
|
|
564
596
|
{#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
|
-
>
|
|
597
|
+
<div class={['min-h-0 relative flex-1', flush ? 'p-0 overflow-hidden' : 'p-3 overflow-auto']}>
|
|
571
598
|
{#if busy}
|
|
572
599
|
{#if loadingMode === 'spinner'}
|
|
573
600
|
<div
|
|
574
|
-
class="
|
|
601
|
+
class="min-h-32 gap-2 flex flex-col items-center justify-center"
|
|
575
602
|
aria-busy="true"
|
|
576
603
|
aria-live="polite"
|
|
577
604
|
>
|
|
@@ -588,10 +615,10 @@
|
|
|
588
615
|
{/if}
|
|
589
616
|
{:else if empty}
|
|
590
617
|
<div
|
|
591
|
-
class="
|
|
618
|
+
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
619
|
>
|
|
593
620
|
<div
|
|
594
|
-
class="mb-3
|
|
621
|
+
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
622
|
>
|
|
596
623
|
<svg
|
|
597
624
|
viewBox="0 0 24 24"
|
|
@@ -614,8 +641,7 @@
|
|
|
614
641
|
{/if}
|
|
615
642
|
|
|
616
643
|
{#if showResizeHandle}
|
|
617
|
-
{#each
|
|
618
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
644
|
+
{#each visibleResizeEdges as h (h.edge)}
|
|
619
645
|
<div
|
|
620
646
|
data-resize-handle
|
|
621
647
|
class={[edgeAccent, h.class]}
|
|
@@ -630,12 +656,18 @@
|
|
|
630
656
|
<!-- SE grip (visual only, small) -->
|
|
631
657
|
<div
|
|
632
658
|
class={[
|
|
633
|
-
'
|
|
634
|
-
'text-muted/60
|
|
659
|
+
'bottom-0.5 right-0.5 h-2.5 w-2.5 pointer-events-none absolute z-20 flex items-center justify-center',
|
|
660
|
+
'text-muted/60 group-hover/widget:text-brand-500 transition-colors'
|
|
635
661
|
]}
|
|
636
662
|
aria-hidden="true"
|
|
637
663
|
>
|
|
638
|
-
<svg
|
|
664
|
+
<svg
|
|
665
|
+
viewBox="0 0 12 12"
|
|
666
|
+
fill="none"
|
|
667
|
+
stroke="currentColor"
|
|
668
|
+
stroke-width="1.75"
|
|
669
|
+
class="h-2.5 w-2.5"
|
|
670
|
+
>
|
|
639
671
|
<path stroke-linecap="round" d="M7 11L11 7M4 11L11 4" />
|
|
640
672
|
</svg>
|
|
641
673
|
</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]',
|
|
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) {
|