@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.
@@ -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(' ');
@@ -440,17 +475,18 @@
440
475
 
441
476
  <!-- svelte-ignore a11y_no_static_element_interactions -->
442
477
  <div
443
- bind:this={rootEl}
478
+ {@attach attachRoot}
444
479
  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',
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 ? 'absolute shadow-md' : 'relative',
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-1 ring-brand-500/40',
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
- 'flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5',
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
- // En freeform, permite arrastrar desde el header completo.
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="truncate text-sm font-semibold leading-none text-primary">{title}</h3>
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 text-muted">{description}</p>
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 gap-0.5">
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
- onclick={() => onremove?.()}
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="flex min-h-32 flex-col items-center justify-center gap-2"
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="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"
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 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"
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 freeformEdges as h (h.edge)}
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
- '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'
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 viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.75" class="h-2.5 w-2.5">
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 { 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]',
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.1",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",