@svadmin/ui 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
package/src/app.css CHANGED
@@ -99,22 +99,25 @@
99
99
  --sidebar-ring: oklch(0.55 0.18 264);
100
100
  }
101
101
 
102
- /* Elygate-style shadows — subtle depth without heaviness */
102
+ /* Stripe-inspired Soft Shadows — premium, multi-layered depth */
103
103
  [data-slot="button"],
104
104
  [data-slot="input"],
105
105
  [data-slot="select-trigger"],
106
106
  [data-slot="select"],
107
107
  [data-slot="textarea"] {
108
- box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
108
+ /* Subtle interaction shadow */
109
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.04), 0 0 0 1px rgb(0 0 0 / 0.02);
109
110
  }
110
111
 
111
112
  [data-slot="card"] {
112
- box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06);
113
+ /* Classic Stripe soft shadow: faint ambient + directional drop */
114
+ box-shadow: 0 2px 8px -2px rgb(0 0 0 / 0.05), 0 4px 16px -4px rgb(0 0 0 / 0.02);
113
115
  }
114
116
 
115
117
  [data-slot="dialog-content"],
116
118
  [data-slot="sheet-content"] {
117
- box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
119
+ /* Deep floating layer for modals */
120
+ box-shadow: 0 10px 30px -5px rgb(0 0 0 / 0.08), 0 20px 40px -10px rgb(0 0 0 / 0.04);
118
121
  }
119
122
  }
120
123
 
@@ -124,6 +127,33 @@ body {
124
127
  -moz-osx-font-smoothing: grayscale;
125
128
  }
126
129
 
130
+ /* Premium Typography Refinements */
131
+ h1, h2, h3 {
132
+ letter-spacing: -0.025em;
133
+ font-weight: 600;
134
+ }
135
+ h4, h5, h6 {
136
+ letter-spacing: -0.01em;
137
+ }
138
+
139
+ /* Micro-interactions: Linear/Apple ecosystem click scales */
140
+ [data-slot="button"],
141
+ button {
142
+ transition: transform 0.1s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.15s ease, box-shadow 0.15s ease;
143
+ }
144
+ [data-slot="button"]:active:not(:disabled),
145
+ button:active:not(:disabled) {
146
+ transform: scale(0.97);
147
+ }
148
+
149
+ /* Subtle glowing focus ring for inputs */
150
+ [data-slot="input"]:focus-visible,
151
+ [data-slot="textarea"]:focus-visible,
152
+ [data-slot="select-trigger"]:focus-visible {
153
+ box-shadow: 0 0 0 1px var(--color-background), 0 0 0 3px color-mix(in oklch, var(--color-primary) 30%, transparent) !important;
154
+ border-color: var(--color-primary);
155
+ }
156
+
127
157
  /* Scrollbar styling */
128
158
  ::-webkit-scrollbar { width: 6px; height: 6px; }
129
159
  ::-webkit-scrollbar-track { background: transparent; }
@@ -132,6 +162,216 @@ body {
132
162
  .dark ::-webkit-scrollbar-thumb { background: oklch(0.35 0 0); }
133
163
  .dark ::-webkit-scrollbar-thumb:hover { background: oklch(0.45 0 0); }
134
164
 
165
+ /* ① Card Hover Elevation Lift — Stripe-style float on hover */
166
+ [data-slot="card"] {
167
+ transition: box-shadow 0.2s ease, transform 0.2s ease;
168
+ }
169
+ [data-slot="card"]:hover {
170
+ box-shadow: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 8px 24px -6px rgb(0 0 0 / 0.04);
171
+ transform: translateY(-1px);
172
+ }
173
+
174
+ /* ② Table Row Hover — primary color tint instead of plain gray */
175
+ [data-slot="table-row"]:hover {
176
+ background-color: color-mix(in oklch, var(--primary) 4%, var(--background)) !important;
177
+ }
178
+ [data-slot="table-row"] {
179
+ transition: background-color 0.15s ease;
180
+ }
181
+
182
+ /* ③ Sidebar Active Indicator — Linear-style left accent bar */
183
+ .sidebar-nav-active {
184
+ position: relative;
185
+ }
186
+ .sidebar-nav-active::before {
187
+ content: '';
188
+ position: absolute;
189
+ left: 0;
190
+ top: 50%;
191
+ transform: translateY(-50%);
192
+ width: 3px;
193
+ height: 60%;
194
+ border-radius: 0 3px 3px 0;
195
+ background: var(--primary);
196
+ opacity: 1;
197
+ transition: opacity 0.2s ease;
198
+ }
199
+
200
+ /* ④ Skeleton Shimmer — metallic light sweep instead of static pulse */
201
+ @keyframes svadmin-shimmer {
202
+ 0% { background-position: -200% 0; }
203
+ 100% { background-position: 200% 0; }
204
+ }
205
+ [data-slot="skeleton"] {
206
+ animation: svadmin-shimmer 1.5s ease-in-out infinite !important;
207
+ background: linear-gradient(90deg, var(--muted) 25%, color-mix(in oklch, var(--muted) 60%, white) 50%, var(--muted) 75%) !important;
208
+ background-size: 200% 100% !important;
209
+ }
210
+ .dark [data-slot="skeleton"] {
211
+ background: linear-gradient(90deg, var(--muted) 25%, color-mix(in oklch, var(--muted) 60%, var(--foreground)) 50%, var(--muted) 75%) !important;
212
+ }
213
+
214
+ /* ⑤ Route Page Transition — fade + subtle slide-up */
215
+ @keyframes svadmin-page-enter {
216
+ from {
217
+ opacity: 0;
218
+ transform: translateY(6px);
219
+ }
220
+ to {
221
+ opacity: 1;
222
+ transform: translateY(0);
223
+ }
224
+ }
225
+ .svadmin-page-enter {
226
+ animation: svadmin-page-enter 0.25s ease-out;
227
+ }
228
+
229
+ /* ⑥ Toast slide-in refinement (svelte-sonner) — tighter timing */
230
+ [data-sonner-toaster] [data-sonner-toast] {
231
+ transition-duration: 200ms !important;
232
+ transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1) !important;
233
+ }
234
+
235
+ /* ⑦ Gradient Top Border on Cards — Vercel dashboard style */
236
+ [data-slot="card"]::before {
237
+ content: '';
238
+ position: absolute;
239
+ top: 0;
240
+ left: 0;
241
+ right: 0;
242
+ height: 1px;
243
+ background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--primary) 20%, transparent), transparent);
244
+ border-radius: inherit;
245
+ pointer-events: none;
246
+ }
247
+ [data-slot="card"] {
248
+ position: relative;
249
+ overflow: hidden;
250
+ }
251
+
252
+ /* ⑧ Dark Mode Primary Glow — Linear-style neon accents */
253
+ .dark [data-slot="button"][data-variant="default"],
254
+ .dark [data-slot="badge"][data-variant="default"] {
255
+ box-shadow: 0 0 12px -3px color-mix(in oklch, var(--primary) 40%, transparent);
256
+ }
257
+
258
+ /* ⑨ Form Validation Shake Animation */
259
+ @keyframes svadmin-shake {
260
+ 0%, 100% { transform: translateX(0); }
261
+ 20% { transform: translateX(-6px); }
262
+ 40% { transform: translateX(5px); }
263
+ 60% { transform: translateX(-4px); }
264
+ 80% { transform: translateX(2px); }
265
+ }
266
+ .svadmin-shake {
267
+ animation: svadmin-shake 0.4s ease-out;
268
+ }
269
+
270
+ /* ⑩ Table Row Staggered Entrance */
271
+ @keyframes svadmin-row-enter {
272
+ from {
273
+ opacity: 0;
274
+ transform: translateY(4px);
275
+ }
276
+ to {
277
+ opacity: 1;
278
+ transform: translateY(0);
279
+ }
280
+ }
281
+ [data-slot="table-body"] [data-slot="table-row"] {
282
+ animation: svadmin-row-enter 0.3s ease-out both;
283
+ }
284
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(1) { animation-delay: 0s; }
285
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(2) { animation-delay: 0.03s; }
286
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(3) { animation-delay: 0.06s; }
287
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(4) { animation-delay: 0.09s; }
288
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(5) { animation-delay: 0.12s; }
289
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(6) { animation-delay: 0.15s; }
290
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(7) { animation-delay: 0.18s; }
291
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(8) { animation-delay: 0.21s; }
292
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(9) { animation-delay: 0.24s; }
293
+ [data-slot="table-body"] [data-slot="table-row"]:nth-child(10) { animation-delay: 0.27s; }
294
+
295
+ /* ⑪ Accessibility: Respect reduced motion preferences */
296
+ @media (prefers-reduced-motion: reduce) {
297
+ *, *::before, *::after {
298
+ animation-duration: 0.01ms !important;
299
+ animation-iteration-count: 1 !important;
300
+ transition-duration: 0.01ms !important;
301
+ scroll-behavior: auto !important;
302
+ }
303
+ }
304
+
305
+ /* ⑫ Optimistic Delete — row fade-out visual feedback */
306
+ @keyframes svadmin-row-delete {
307
+ from {
308
+ opacity: 1;
309
+ transform: translateX(0);
310
+ }
311
+ to {
312
+ opacity: 0;
313
+ transform: translateX(20px);
314
+ height: 0;
315
+ padding: 0;
316
+ margin: 0;
317
+ }
318
+ }
319
+ .svadmin-deleting {
320
+ animation: svadmin-row-delete 0.3s ease-out forwards;
321
+ pointer-events: none;
322
+ }
323
+
324
+ /* ⑬ Popover & Dropdown — frosted glass backdrop + spring entrance */
325
+ [data-slot="popover-content"],
326
+ [data-slot="dropdown-menu-content"],
327
+ [data-slot="context-menu-content"] {
328
+ backdrop-filter: blur(8px);
329
+ -webkit-backdrop-filter: blur(8px);
330
+ }
331
+
332
+ /* ⑭ Tooltip spring entrance */
333
+ [data-slot="tooltip-content"] {
334
+ animation: svadmin-tooltip-enter 0.15s cubic-bezier(0.16, 1, 0.3, 1);
335
+ }
336
+ @keyframes svadmin-tooltip-enter {
337
+ from {
338
+ opacity: 0;
339
+ transform: scale(0.96);
340
+ }
341
+ to {
342
+ opacity: 1;
343
+ transform: scale(1);
344
+ }
345
+ }
346
+
347
+ /* ⑮ Smooth scrolling for content areas */
348
+ main {
349
+ scroll-behavior: smooth;
350
+ }
351
+
352
+ /* ⑯ RTL (Right-to-Left) Language Support */
353
+ [dir="rtl"] {
354
+ direction: rtl;
355
+ }
356
+ [dir="rtl"] aside {
357
+ left: auto;
358
+ right: 0;
359
+ }
360
+ [dir="rtl"] .sidebar-nav-active::before {
361
+ left: auto;
362
+ right: 0;
363
+ border-radius: 3px 0 0 3px;
364
+ }
365
+ /* Mirror sidebar margins for RTL */
366
+ [dir="rtl"] .md\:ml-64 {
367
+ margin-left: 0;
368
+ margin-right: 16rem;
369
+ }
370
+ [dir="rtl"] .md\:ml-16 {
371
+ margin-left: 0;
372
+ margin-right: 4rem;
373
+ }
374
+
135
375
  /* Tailwind CSS v4 — Map design tokens to utility classes */
136
376
  @theme {
137
377
  --color-background: var(--background);
@@ -144,6 +144,8 @@
144
144
  <ConfigErrorScreen title="{title} — {t('common.configRequired')}" />
145
145
  {:else if isAuthenticated || !authProvider}
146
146
  <Layout {title}>
147
+ {#key route + (params.resource ?? '') + (params.id ?? '')}
148
+ <div class="svadmin-page-enter">
147
149
  {#if route === '/'}
148
150
  {#if dashboard}
149
151
  {@render dashboard()}
@@ -170,6 +172,8 @@
170
172
  <ShowPage resourceName={params.resource} id={params.id} />
171
173
  {/key}
172
174
  {/if}
175
+ </div>
176
+ {/key}
173
177
  </Layout>
174
178
  {:else}
175
179
  <div class="flex h-screen items-center justify-center">
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+
4
+ interface Props {
5
+ /** The target number to animate to */
6
+ value: number | string;
7
+ /** Animation duration in milliseconds */
8
+ duration?: number;
9
+ /** CSS class for the container */
10
+ class?: string;
11
+ }
12
+
13
+ let { value, duration = 600, class: className = '' }: Props = $props();
14
+
15
+ let displayValue = $state('0');
16
+ let prevTarget = 0;
17
+
18
+ $effect(() => {
19
+ const target = typeof value === 'number' ? value : parseInt(value, 10);
20
+ if (isNaN(target)) {
21
+ displayValue = String(value);
22
+ return;
23
+ }
24
+
25
+ const start = untrack(() => prevTarget);
26
+ prevTarget = target;
27
+ const startTime = performance.now();
28
+
29
+ function tick() {
30
+ const elapsed = performance.now() - startTime;
31
+ const progress = Math.min(elapsed / duration, 1);
32
+ // Ease-out cubic
33
+ const eased = 1 - Math.pow(1 - progress, 3);
34
+ const current = Math.round(start + (target - start) * eased);
35
+ displayValue = current.toLocaleString();
36
+
37
+ if (progress < 1) {
38
+ requestAnimationFrame(tick);
39
+ }
40
+ }
41
+
42
+ requestAnimationFrame(tick);
43
+ });
44
+ </script>
45
+
46
+ <span class={className}>{displayValue}</span>
@@ -35,6 +35,7 @@
35
35
  } from 'lucide-svelte';
36
36
  import ConfirmDialog from './ConfirmDialog.svelte';
37
37
  import TooltipButton from './TooltipButton.svelte';
38
+ import InlineEdit from './InlineEdit.svelte';
38
39
  import type { Snippet } from 'svelte';
39
40
 
40
41
  // ─── Props with Snippet composability ─────────────────────────
@@ -144,6 +145,15 @@
144
145
  sorters.map(s => ({ id: s.field, desc: s.order === 'desc' }))
145
146
  ));
146
147
  let columnVisibility = $state<VisibilityState>((() => {
148
+ // Try to restore from localStorage first
149
+ const storageKey = `svadmin-columns-${resourceName}`;
150
+ if (typeof localStorage !== 'undefined') {
151
+ try {
152
+ const stored = localStorage.getItem(storageKey);
153
+ if (stored) return JSON.parse(stored);
154
+ } catch { /* ignore */ }
155
+ }
156
+ // Fall back to resource field definitions
147
157
  const vis: VisibilityState = {};
148
158
  for (const f of resource.fields) {
149
159
  if (f.showInList === false) vis[f.key] = false;
@@ -152,6 +162,17 @@
152
162
  })());
153
163
  let rowSelection = $state<RowSelectionState>({});
154
164
  let expanded = $state.raw<ExpandedState>({});
165
+ let columnOrder = $state<string[]>([]);
166
+
167
+ // Persist column visibility to localStorage
168
+ $effect(() => {
169
+ const storageKey = `svadmin-columns-${resourceName}`;
170
+ if (typeof localStorage !== 'undefined') {
171
+ try {
172
+ localStorage.setItem(storageKey, JSON.stringify(columnVisibility));
173
+ } catch { /* ignore quota errors */ }
174
+ }
175
+ });
155
176
 
156
177
  // Sync TanStack sorting → server sorters
157
178
  $effect(() => {
@@ -216,6 +237,7 @@
216
237
  get columnVisibility() { return columnVisibility; },
217
238
  get rowSelection() { return rowSelection; },
218
239
  get expanded() { return expanded; },
240
+ get columnOrder() { return columnOrder; },
219
241
  },
220
242
  onSortingChange: (updater) => {
221
243
  sorting = typeof updater === 'function' ? updater(sorting) : updater;
@@ -229,6 +251,9 @@
229
251
  onExpandedChange: (updater) => {
230
252
  expanded = typeof updater === 'function' ? updater(expanded) : updater;
231
253
  },
254
+ onColumnOrderChange: (updater) => {
255
+ columnOrder = typeof updater === 'function' ? updater(columnOrder) : updater;
256
+ },
232
257
  get enableRowSelection() { return selectable && canDelete; },
233
258
  get enableExpanding() { return !!expandedRowRender; },
234
259
  });
@@ -414,7 +439,7 @@
414
439
  {/if}
415
440
 
416
441
  <!-- Table (TanStack-powered) -->
417
- <div class="rounded-lg bg-card shadow-sm overflow-hidden">
442
+ <div class="rounded-lg bg-card shadow-sm overflow-hidden" role="region" aria-label="{resource.label} {t('common.list')}">
418
443
  {#if query.isLoading}
419
444
  <div class="p-4 space-y-3">
420
445
  <div class="flex gap-4 mb-2">
@@ -441,9 +466,16 @@
441
466
  <Table.Root>
442
467
  <Table.Header>
443
468
  {#each tbl.getHeaderGroups() as headerGroup}
444
- <Table.Row class="bg-muted/50 hover:bg-muted/50">
445
- {#each headerGroup.headers as header}
469
+ <DraggableHeader
470
+ columns={headerGroup.headers.map(h => ({ id: h.column.id, header: h }))}
471
+ resourceName={resourceName}
472
+ onReorder={(newOrder) => { columnOrder = newOrder.map(c => c.id); }}
473
+ >
474
+ {#snippet header(col, index, dragProps)}
475
+ {@const header = col.header as typeof headerGroup.headers[0]}
446
476
  <Table.Head
477
+ {...dragProps}
478
+ class={cn("bg-muted/50 hover:bg-muted/50", dragProps.class)}
447
479
  style={header.getSize() !== 150 ? `width:${header.getSize()}px` : undefined}
448
480
  >
449
481
  {#if header.id === '_select'}
@@ -474,8 +506,8 @@
474
506
  {visibleFields.find(f => f.key === header.id)?.label ?? header.id}
475
507
  {/if}
476
508
  </Table.Head>
477
- {/each}
478
- </Table.Row>
509
+ {/snippet}
510
+ </DraggableHeader>
479
511
  {/each}
480
512
  </Table.Header>
481
513
  <Table.Body>
@@ -535,6 +567,14 @@
535
567
  {:else if field?.type === 'select' && field.options}
536
568
  {@const opt = field.options.find(o => o.value === cell.getValue())}
537
569
  <Badge variant="outline">{opt?.label ?? cell.getValue() ?? '—'}</Badge>
570
+ {:else if canEdit && field && ['text', 'number', 'email', 'url'].includes(field.type)}
571
+ <InlineEdit
572
+ {resourceName}
573
+ recordId={id}
574
+ {field}
575
+ value={cell.getValue()}
576
+ onSave={() => listResult.query.refetch()}
577
+ />
538
578
  {:else}
539
579
  {cell.getValue() ?? '—'}
540
580
  {/if}
@@ -573,11 +613,23 @@
573
613
  {/if}
574
614
  {:else}
575
615
  <Table.Row>
576
- <Table.Cell colspan={columns.length} class="h-24 text-center text-muted-foreground">
616
+ <Table.Cell colspan={columns.length} class="h-48 text-center">
577
617
  {#if emptyState}
578
618
  {@render emptyState()}
579
619
  {:else}
580
- {t('common.noData')}
620
+ <div class="flex flex-col items-center justify-center py-8">
621
+ <svg class="h-16 w-16 text-muted-foreground/30 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
622
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
623
+ </svg>
624
+ <p class="text-sm font-medium text-muted-foreground mb-1">{t('common.noData')}</p>
625
+ <p class="text-xs text-muted-foreground/60 mb-4">{t('common.noDataHint') || 'Get started by creating your first record.'}</p>
626
+ {#if canCreate}
627
+ <Button variant="outline" size="sm" class="gap-2" onclick={() => navigate(`/${resourceName}/create`)}>
628
+ <Plus class="h-3.5 w-3.5" />
629
+ {t('common.create')}
630
+ </Button>
631
+ {/if}
632
+ </div>
581
633
  {/if}
582
634
  </Table.Cell>
583
635
  </Table.Row>
@@ -39,16 +39,18 @@
39
39
  {#if crumbs.length > 1}
40
40
  <Breadcrumb.Root class="mb-4">
41
41
  <Breadcrumb.List>
42
- {#each crumbs as crumb, i}
42
+ {#each crumbs as crumb, i (crumb.href)}
43
43
  {#if i > 0}
44
44
  <Breadcrumb.Separator />
45
45
  {/if}
46
46
  <Breadcrumb.Item>
47
+ <span class="inline-flex svadmin-page-enter" style="animation-duration: 0.2s;">
47
48
  {#if i === crumbs.length - 1}
48
49
  <Breadcrumb.Page>{crumb.label}</Breadcrumb.Page>
49
50
  {:else}
50
51
  <Breadcrumb.Link href={crumb.href}>{crumb.label}</Breadcrumb.Link>
51
52
  {/if}
53
+ </span>
52
54
  </Breadcrumb.Item>
53
55
  {/each}
54
56
  </Breadcrumb.List>
@@ -0,0 +1,112 @@
1
+ <script lang="ts">
2
+ /**
3
+ * DraggableHeader — Wraps table header cells with HTML5 Drag and Drop
4
+ * to enable column reorder. Persists order to localStorage.
5
+ *
6
+ * Usage:
7
+ * <DraggableHeader columns={columns} resourceName="posts" onReorder={handleReorder}>
8
+ * {#snippet header(column, index)}
9
+ * <th>...</th>
10
+ * {/snippet}
11
+ * </DraggableHeader>
12
+ */
13
+ import type { Snippet } from 'svelte';
14
+
15
+ interface Column {
16
+ id: string;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ interface Props {
21
+ columns: Column[];
22
+ resourceName: string;
23
+ onReorder: (newOrder: Column[]) => void;
24
+ header: Snippet<[Column, number, Record<string, any>]>;
25
+ }
26
+
27
+ let { columns, resourceName, onReorder, header }: Props = $props();
28
+
29
+ let dragIndex = $state<number | null>(null);
30
+ let dropIndex = $state<number | null>(null);
31
+
32
+ // Restore saved order from localStorage
33
+ $effect(() => {
34
+ const storageKey = `svadmin-colorder-${resourceName}`;
35
+ if (typeof localStorage === 'undefined') return;
36
+ try {
37
+ const saved = localStorage.getItem(storageKey);
38
+ if (saved) {
39
+ const savedOrder: string[] = JSON.parse(saved);
40
+ const reordered = savedOrder
41
+ .map(id => columns.find(c => c.id === id))
42
+ .filter((c): c is Column => c !== undefined);
43
+ // Add any new columns not in saved order
44
+ const remaining = columns.filter(c => !savedOrder.includes(c.id));
45
+ if (reordered.length > 0) {
46
+ onReorder([...reordered, ...remaining]);
47
+ }
48
+ }
49
+ } catch { /* ignore */ }
50
+ });
51
+
52
+ function handleDragStart(e: DragEvent, index: number) {
53
+ dragIndex = index;
54
+ if (e.dataTransfer) {
55
+ e.dataTransfer.effectAllowed = 'move';
56
+ e.dataTransfer.setData('text/plain', String(index));
57
+ }
58
+ }
59
+
60
+ function handleDragOver(e: DragEvent, index: number) {
61
+ e.preventDefault();
62
+ dropIndex = index;
63
+ if (e.dataTransfer) {
64
+ e.dataTransfer.dropEffect = 'move';
65
+ }
66
+ }
67
+
68
+ function handleDrop(e: DragEvent, index: number) {
69
+ e.preventDefault();
70
+ if (dragIndex === null || dragIndex === index) {
71
+ dragIndex = null;
72
+ dropIndex = null;
73
+ return;
74
+ }
75
+
76
+ const newColumns = [...columns];
77
+ const [moved] = newColumns.splice(dragIndex, 1);
78
+ newColumns.splice(index, 0, moved);
79
+
80
+ // Persist to localStorage
81
+ const storageKey = `svadmin-colorder-${resourceName}`;
82
+ if (typeof localStorage !== 'undefined') {
83
+ try {
84
+ localStorage.setItem(storageKey, JSON.stringify(newColumns.map(c => c.id)));
85
+ } catch { /* ignore */ }
86
+ }
87
+
88
+ onReorder(newColumns);
89
+ dragIndex = null;
90
+ dropIndex = null;
91
+ }
92
+
93
+ function handleDragEnd() {
94
+ dragIndex = null;
95
+ dropIndex = null;
96
+ }
97
+ </script>
98
+
99
+ <tr class="hover:bg-muted/50 data-[state=selected]:bg-muted transition-colors bg-muted/50">
100
+ {#each columns as column, index}
101
+ {@render header(column, index, {
102
+ draggable: true,
103
+ ondragstart: (e: DragEvent) => handleDragStart(e, index),
104
+ ondragover: (e: DragEvent) => handleDragOver(e, index),
105
+ ondrop: (e: DragEvent) => handleDrop(e, index),
106
+ ondragend: handleDragEnd,
107
+ 'data-dragging': dragIndex === index,
108
+ 'data-drop-target': dropIndex === index && dropIndex !== dragIndex,
109
+ class: `cursor-grab active:cursor-grabbing select-none transition-opacity ${dragIndex === index ? 'opacity-50' : ''} ${dropIndex === index && dropIndex !== dragIndex ? 'border-l-2 border-primary' : ''}`
110
+ })}
111
+ {/each}
112
+ </tr>