@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.
@@ -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) || (o.breadcrumb?.toLowerCase().includes(q) ?? false)
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 breadcrumb}
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
- {breadcrumb}
659
+ {secondary}
654
660
  </span>
655
661
  {/if}
656
662
  </span>
@@ -1,6 +1,8 @@
1
1
  export interface SelectOption {
2
2
  value: string;
3
3
  label: string;
4
+ /** Secondary line under the label (e.g. email). Also included in search. */
5
+ hint?: string;
4
6
  disabled?: boolean;
5
7
  children?: SelectOption[];
6
8
  }
@@ -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 = $state<HTMLDivElement | null>(null);
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
- { edge: 'n', class: 'left-1/2 top-0 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-n-resize' },
383
- { edge: 's', class: 'left-1/2 bottom-0 h-3 w-3 -translate-x-1/2 translate-y-1/2 cursor-s-resize' },
384
- { edge: 'e', class: 'top-1/2 right-0 h-3 w-3 translate-x-1/2 -translate-y-1/2 cursor-e-resize' },
385
- { edge: 'w', class: 'top-1/2 left-0 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-w-resize' },
386
- { edge: 'nw', class: 'left-0 top-0 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize' },
387
- { edge: 'ne', class: 'right-0 top-0 h-3 w-3 translate-x-1/2 -translate-y-1/2 cursor-ne-resize' },
388
- { edge: 'sw', class: 'bottom-0 left-0 h-3 w-3 -translate-x-1/2 translate-y-1/2 cursor-sw-resize' },
389
- { edge: 'se', class: 'bottom-0 right-0 h-3 w-3 translate-x-1/2 translate-y-1/2 cursor-se-resize' }
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
- bind:this={rootEl}
489
+ {@attach attachRoot}
444
490
  class={[
445
- 'group/widget flex min-h-0 min-w-0 flex-col overflow-visible',
446
- !isCanva && 'overflow-hidden rounded-xl border border-border bg-surface-elevated',
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 ? 'absolute shadow-md' : 'relative',
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-1 ring-brand-500/40',
499
+ mode === 'resize' && !isCanva && 'ring-brand-500/40 ring-1',
454
500
  !showChrome && freeform && draggable && 'cursor-move',
455
- className
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
- 'flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5',
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
- // En freeform, permite arrastrar desde el header completo.
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="truncate text-sm font-semibold leading-none text-primary">{title}</h3>
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 text-muted">{description}</p>
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 gap-0.5">
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
- onclick={() => onremove?.()}
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="flex min-h-32 flex-col items-center justify-center gap-2"
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="flex h-full min-h-32 flex-col items-center justify-center rounded-lg border border-dashed border-border bg-surface/50 px-4 py-6 text-center"
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 flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600 dark:bg-brand-950/50 dark:text-brand-400"
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 freeformEdges as h (h.edge)}
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
- 'pointer-events-none absolute bottom-0.5 right-0.5 z-20 flex h-2.5 w-2.5 items-center justify-center',
634
- 'text-muted/60 transition-colors group-hover/widget:text-brand-500'
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 viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.75" class="h-2.5 w-2.5">
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 { Snippet } from 'svelte';
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
- let containerEl = $state<HTMLDivElement | null>(null);
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 = new Map<string, DashboardWidgetMeta>();
109
- for (const w of widgets) map.set(w.id, w);
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.has(it.id)) map.set(it.id, { id: it.id, title: it.id });
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
- Math.max(contentRows + (editable ? padRows : 0), minRows)
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 onDragStart(id: string, e: PointerEvent) {
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
- (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId);
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 = Math.round((e.clientX - origin.x) / (cw + gap));
206
- const dy = Math.round((e.clientY - origin.y) / (rh + gap));
207
- emit(
208
- updateItem(
209
- layout,
210
- draggingId,
211
- { x: origin.itemX + dx, y: origin.itemY + dy },
212
- { cols, compact }
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
- draggingId = null;
247
- resizingId = null;
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={['w-full space-y-4', className]}>
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
- onreset={onreset}
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
- bind:this={containerEl}
497
+ {@attach attachContainer}
308
498
  class={[
309
- 'relative w-full overflow-hidden rounded-xl',
310
- editable && showGrid && 'ring-1 ring-border'
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="pointer-events-none rounded-lg border border-dashed border-border/50 bg-surface-overlay/25"
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 = metaById.get(item.id)!}
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
- 'relative z-[1] min-h-0 min-w-0',
336
- (draggingId === item.id || resizingId === item.id) && 'z-10 opacity-95'
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 && showSizeBadge}
341
- <span class="pointer-events-none absolute right-2 top-2 z-20">
342
- <Badge size="sm" variant="secondary" class="tabular-nums">
343
- {clamped.w}×{clamped.h}
344
- {#if item.static}
345
- · locked
346
- {/if}
347
- </Badge>
348
- </span>
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 gap-2 p-3">
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 text-muted">
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 { Snippet } from 'svelte';
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[];
@@ -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 next = layout.map((it) => (it.id === moved.id ? clampItem(moved, cols) : { ...it }));
30
- const idx = next.findIndex((it) => it.id === moved.id);
31
- if (idx < 0)
32
- return next;
33
- const sorted = [...next].sort((a, b) => a.y - b.y || a.x - b.x);
34
- const result = [];
35
- for (const item of sorted) {
36
- let current = clampItem(item, cols);
37
- if (current.static) {
38
- result.push(current);
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 (result.some((other) => rectsOverlap(current, other)) && safety < 200) {
43
- const blocker = result.find((other) => rectsOverlap(current, other));
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
- result.push(current);
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 result;
51
+ return placed;
50
52
  }
51
53
  /** Compact items upward to remove vertical gaps. */
52
54
  export function compactLayout(layout, cols = DEFAULT_COLS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",