@svadmin/ui 0.38.5 → 0.38.7

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.
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { untrack } from 'svelte';
2
+ import { onDestroy, untrack } from 'svelte';
3
3
  import { cn } from '../utils.js';
4
4
  import {
5
5
  createTable,
@@ -10,23 +10,21 @@
10
10
  type ColumnVisibilityState,
11
11
  type ExpandedState,
12
12
  } from '@tanstack/svelte-table';
13
- import type { Column, Header, TableFeatures, Updater } from '@tanstack/table-core';
13
+ import type { Column, Header, TableFeatures } from '@tanstack/table-core';
14
+ import { createAtom, useSelector } from '@tanstack/svelte-store';
14
15
  import {
15
16
  column_getCanSort,
16
17
  column_getIsSorted,
17
18
  column_toggleSorting,
18
- column_getIsVisible,
19
19
  column_toggleVisibility,
20
20
  header_getSize,
21
21
  table_getRowModel,
22
- table_getHeaderGroups,
23
22
  table_getAllLeafColumns,
24
23
  table_getIsAllRowsSelected,
25
24
  table_toggleAllRowsSelected,
26
25
  row_getIsSelected,
27
26
  row_toggleSelected,
28
27
  row_getVisibleCells,
29
- row_getIsExpanded,
30
28
  row_toggleExpanded,
31
29
  cell_getValue,
32
30
  } from '@tanstack/table-core/static-functions';
@@ -130,6 +128,22 @@
130
128
  );
131
129
  let filters = $state<Filter[]>([]);
132
130
  let searchText = $state(urlState.search ?? '');
131
+ let appliedSearchText = $state(untrack(() => searchText));
132
+ let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined;
133
+
134
+ function scheduleSearch(event: Event) {
135
+ searchText = (event.currentTarget as HTMLInputElement).value;
136
+ if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
137
+ searchDebounceTimer = setTimeout(() => {
138
+ appliedSearchText = searchText;
139
+ pagination = { ...pagination, current: 1 };
140
+ searchDebounceTimer = undefined;
141
+ }, 300);
142
+ }
143
+
144
+ onDestroy(() => {
145
+ if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
146
+ });
133
147
 
134
148
  // Sync external controlled state
135
149
  $effect(() => {
@@ -151,7 +165,7 @@
151
165
  pageSize: pagination.pageSize,
152
166
  sortField: sorters[0]?.field,
153
167
  sortOrder: sorters[0]?.order,
154
- search: searchText || undefined,
168
+ search: appliedSearchText || undefined,
155
169
  }, adminContext);
156
170
  });
157
171
 
@@ -162,16 +176,16 @@
162
176
  const activeFilterCount = $derived(Object.values(filterValues).filter(v => v.trim()).length);
163
177
  const activeFilters = $derived.by(() => {
164
178
  const result: Filter[] = [...filters];
165
- if (searchText.trim() && searchableFields.length > 0) {
179
+ if (appliedSearchText.trim() && searchableFields.length > 0) {
166
180
  if (searchableFields.length === 1) {
167
- result.push({ field: searchableFields[0].key, operator: 'contains', value: searchText });
181
+ result.push({ field: searchableFields[0].key, operator: 'contains', value: appliedSearchText });
168
182
  } else {
169
183
  const searchFilter: LogicalFilter = {
170
184
  operator: 'or',
171
185
  value: searchableFields.map(f => ({
172
186
  field: f.key,
173
187
  operator: 'contains',
174
- value: searchText
188
+ value: appliedSearchText
175
189
  }))
176
190
  };
177
191
  result.push(searchFilter);
@@ -251,10 +265,10 @@
251
265
  const canExport = $derived(canExportPerm.allowed);
252
266
 
253
267
  // ─── TanStack Table state ────────────────────────────────────
254
- let sorting = $state<SortingState>(untrack(() =>
268
+ const initialSorting = untrack<SortingState>(() =>
255
269
  sorters.map(s => ({ id: s.field, desc: s.order === 'desc' }))
256
- ));
257
- let columnVisibility = $state<ColumnVisibilityState>((() => {
270
+ );
271
+ const initialColumnVisibility = (() => {
258
272
  // Try to restore from localStorage first
259
273
  const storageKey = `svadmin-columns-${resourceName}`;
260
274
  if (typeof window !== 'undefined') {
@@ -269,79 +283,41 @@
269
283
  if (f.showInList === false) vis[f.key] = false;
270
284
  }
271
285
  return vis;
272
- })());
273
- let rowSelection = $state<RowSelectionState>({});
274
- let expanded = $state.raw<ExpandedState>({});
275
- let columnOrder = $state<string[]>([]);
276
-
277
- function applyTableUpdater<T>(current: T, updater: Updater<T>): T {
278
- return typeof updater === 'function' ? (updater as (old: T) => T)(current) : updater;
286
+ })();
287
+ const sortingAtom = createAtom(initialSorting);
288
+ const columnVisibilityAtom = createAtom(initialColumnVisibility);
289
+ const rowSelectionAtom = createAtom({} as RowSelectionState);
290
+ const expandedAtom = createAtom({} as ExpandedState);
291
+ const columnOrderAtom = createAtom([] as string[]);
292
+
293
+ const tableSorting = useSelector(sortingAtom);
294
+ const tableColumnVisibility = useSelector(columnVisibilityAtom);
295
+ const tableRowSelection = useSelector(rowSelectionAtom);
296
+ const tableExpanded = useSelector(expandedAtom);
297
+ const tableColumnOrder = useSelector(columnOrderAtom);
298
+
299
+ function rowIsExpanded(rowId: string): boolean {
300
+ const expanded = tableExpanded.current;
301
+ return expanded === true || expanded[rowId] === true;
279
302
  }
280
303
 
281
- const tableSorting = $derived<SortingState>(sorting.map(sorter => ({
282
- id: sorter.id,
283
- desc: sorter.desc,
284
- })));
285
- const tableColumnVisibility = $derived<ColumnVisibilityState>({ ...columnVisibility });
286
- const tableRowSelection = $derived<RowSelectionState>({ ...rowSelection });
287
- const tableExpanded = $derived<ExpandedState>(expanded === true ? true : { ...expanded });
288
- const tableColumnOrder = $derived<string[]>([...columnOrder]);
289
-
290
- const sortingAtom = {
291
- get: () => tableSorting,
292
- set: (updater: Updater<SortingState>) => {
293
- const nextSorting = applyTableUpdater(tableSorting, updater);
294
- sorting = nextSorting;
295
-
296
- const nextSorters: Sort[] = nextSorting.map((s) => ({
297
- field: s.id,
298
- order: s.desc ? 'desc' as const : 'asc' as const,
299
- }));
300
-
301
- if (JSON.stringify(nextSorters) !== JSON.stringify(sorters)) {
302
- sorters = nextSorters;
303
- }
304
-
305
- if ((pagination.current ?? 1) !== 1) {
306
- pagination = { ...pagination, current: 1 };
307
- }
308
- },
309
- };
310
-
311
- const columnVisibilityAtom = {
312
- get: () => tableColumnVisibility,
313
- set: (updater: Updater<ColumnVisibilityState>) => {
314
- columnVisibility = applyTableUpdater(tableColumnVisibility, updater);
315
- },
316
- };
317
-
318
- const rowSelectionAtom = {
319
- get: () => tableRowSelection,
320
- set: (updater: Updater<RowSelectionState>) => {
321
- rowSelection = applyTableUpdater(tableRowSelection, updater);
322
- },
323
- };
324
-
325
- const expandedAtom = {
326
- get: () => tableExpanded,
327
- set: (updater: Updater<ExpandedState>) => {
328
- expanded = applyTableUpdater(tableExpanded, updater);
329
- },
330
- };
304
+ $effect(() => {
305
+ const nextSorters: Sort[] = tableSorting.current.map((sorter) => ({
306
+ field: sorter.id,
307
+ order: sorter.desc ? 'desc' as const : 'asc' as const,
308
+ }));
331
309
 
332
- const columnOrderAtom = {
333
- get: () => tableColumnOrder,
334
- set: (updater: Updater<string[]>) => {
335
- columnOrder = applyTableUpdater(tableColumnOrder, updater);
336
- },
337
- };
310
+ if (JSON.stringify(nextSorters) !== JSON.stringify(sorters)) {
311
+ sorters = nextSorters;
312
+ }
313
+ });
338
314
 
339
315
  // Persist column visibility to localStorage
340
316
  $effect(() => {
341
317
  const storageKey = `svadmin-columns-${resourceName}`;
342
318
  if (typeof window !== 'undefined') {
343
319
  try {
344
- localStorage.setItem(storageKey, JSON.stringify(columnVisibility));
320
+ localStorage.setItem(storageKey, JSON.stringify(tableColumnVisibility.current));
345
321
  } catch { /* ignore quota errors */ }
346
322
  }
347
323
  });
@@ -353,8 +329,8 @@
353
329
  sorters = externalSorters;
354
330
  }
355
331
  const nextSorting = externalSorters.map(s => ({ id: s.field, desc: s.order === 'desc' }));
356
- if (JSON.stringify(nextSorting) !== JSON.stringify(sorting)) {
357
- sorting = nextSorting;
332
+ if (JSON.stringify(nextSorting) !== JSON.stringify(tableSorting.current)) {
333
+ sortingAtom.set(nextSorting);
358
334
  }
359
335
  });
360
336
 
@@ -397,12 +373,25 @@
397
373
  enableSorting: false,
398
374
  },
399
375
  ]);
376
+ const orderedColumns = $derived.by(() => {
377
+ const columnOrder = tableColumnOrder.current;
378
+ if (!columnOrder.length) return columns;
379
+
380
+ const columnsById = new Map(columns.map((column) => [column.id, column]));
381
+ return [
382
+ ...columnOrder.flatMap((id) => {
383
+ const column = columnsById.get(id);
384
+ return column ? [column] : [];
385
+ }),
386
+ ...columns.filter((column) => !columnOrder.includes(column.id ?? '')),
387
+ ];
388
+ });
400
389
 
401
390
  // ─── Create TanStack Table ────────────────────────────────────
402
391
  const tbl = createTable(
403
392
  {
404
393
  get data() { return query.data?.data ?? []; },
405
- get columns() { return columns; },
394
+ get columns() { return orderedColumns; },
406
395
  getCoreRowModel: createCoreRowModel(),
407
396
  manualPagination: true,
408
397
  manualSorting: true,
@@ -412,15 +401,38 @@
412
401
  columnVisibility: columnVisibilityAtom,
413
402
  rowSelection: rowSelectionAtom,
414
403
  expanded: expandedAtom,
415
- columnOrder: columnOrderAtom,
416
404
  },
405
+ onSortingChange: sortingAtom.set,
406
+ onColumnVisibilityChange: columnVisibilityAtom.set,
407
+ onRowSelectionChange: rowSelectionAtom.set,
408
+ onExpandedChange: expandedAtom.set,
417
409
  get enableRowSelection() { return selectable && (canDelete || batchActions); },
418
410
  get enableExpanding() { return !!expandedRowRender; },
411
+ getRowCanExpand: () => !!expandedRowRender,
419
412
  },
413
+ // AutoTable reads focused atoms directly; selecting the full state triggers Svelte proxy equality warnings.
420
414
  () => undefined,
421
415
  );
422
416
 
423
- const selectedCount = $derived(Object.keys(rowSelection).length);
417
+ $effect.pre(() => {
418
+ const nextColumns = orderedColumns;
419
+ untrack(() => {
420
+ tbl.setOptions({ ...tbl.options, columns: nextColumns });
421
+ });
422
+ });
423
+
424
+ const selectedCount = $derived(Object.keys(tableRowSelection.current).length);
425
+ const tableView = $derived.by(() => {
426
+ void tableSorting.current;
427
+ void tableColumnVisibility.current;
428
+ void tableRowSelection.current;
429
+ void tableExpanded.current;
430
+ void tableColumnOrder.current;
431
+ return {
432
+ headerGroups: tbl.getHeaderGroups(),
433
+ rows: table_getRowModel(tbl).rows,
434
+ };
435
+ });
424
436
  const totalPages = $derived(Math.ceil((query.data?.total ?? 0) / (pagination.pageSize ?? 10)));
425
437
 
426
438
  // ─── Pagination helpers ───────────────────────────────────────
@@ -456,7 +468,7 @@
456
468
  }
457
469
 
458
470
  function confirmBatchDelete() {
459
- const ids = Object.keys(rowSelection);
471
+ const ids = Object.keys(tableRowSelection.current);
460
472
  confirmMessage = i18n.t('common.batchDeleteConfirm', { count: ids.length });
461
473
  confirmAction = async () => {
462
474
  try {
@@ -465,7 +477,7 @@
465
477
  } catch {
466
478
  notify({ type: 'error', message: i18n.t('common.batchDeletePartialFail', { failed: 1, total: ids.length }) });
467
479
  }
468
- rowSelection = {};
480
+ rowSelectionAtom.set({});
469
481
  confirmOpen = false;
470
482
  };
471
483
  confirmOpen = true;
@@ -520,7 +532,7 @@
520
532
  {/if}
521
533
 
522
534
  {#if selectedCount > 0 && batchActions}
523
- {@render batchActions({ selectedIds: Object.keys(rowSelection) })}
535
+ {@render batchActions({ selectedIds: Object.keys(tableRowSelection.current) })}
524
536
  {/if}
525
537
 
526
538
  <!-- Column Visibility Picker (DropdownMenu) -->
@@ -528,14 +540,14 @@
528
540
  <DropdownMenu.Trigger>
529
541
  {#snippet child({ props })}
530
542
  <Button variant="outline" size="sm" {...props}>
531
- <SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.columns') || 'Columns'}
543
+ <SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.columns')}
532
544
  </Button>
533
545
  {/snippet}
534
546
  </DropdownMenu.Trigger>
535
547
  <DropdownMenu.Content align="end" class="w-48">
536
548
  {#each table_getAllLeafColumns(tbl).filter((column: Column<TableFeatures, BaseRecord, unknown>) => !column.id.startsWith('_')) as column, _i (_i)}
537
549
  <DropdownMenu.CheckboxItem
538
- checked={column_getIsVisible(column)}
550
+ checked={tableColumnVisibility.current[column.id] ?? true}
539
551
  onCheckedChange={(v) => column_toggleVisibility(column, !!v)}
540
552
  >
541
553
  {visibleFields.find(f => f.key === column.id)?.label ?? column.id}
@@ -561,8 +573,8 @@
561
573
  <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
562
574
  <Input
563
575
  type="text"
564
- bind:value={searchText}
565
- oninput={() => { pagination = { ...pagination, current: 1 }; }}
576
+ value={searchText}
577
+ oninput={scheduleSearch}
566
578
  placeholder={i18n.t('common.search')}
567
579
  class="pl-10 h-9"
568
580
  />
@@ -656,11 +668,11 @@
656
668
  <div class="hidden md:block">
657
669
  <Table.Root>
658
670
  <Table.Header>
659
- {#each table_getHeaderGroups(tbl) as headerGroup, _i (_i)}
671
+ {#each tableView.headerGroups as headerGroup, _i (_i)}
660
672
  <DraggableHeader
661
673
  columns={headerGroup.headers.map((header: Header<TableFeatures, BaseRecord, unknown>) => ({ id: header.column.id, header }))}
662
674
  resourceName={resourceName}
663
- onReorder={(newOrder) => { columnOrder = newOrder.map((column) => column.id); }}
675
+ onReorder={(newOrder) => columnOrderAtom.set(newOrder.map((column) => column.id))}
664
676
  >
665
677
  {#snippet header(col, _index, dragProps)}
666
678
  {@const header = col.header as typeof headerGroup.headers[0]}
@@ -702,7 +714,7 @@
702
714
  {/each}
703
715
  </Table.Header>
704
716
  <Table.Body>
705
- {#each table_getRowModel(tbl).rows as row, _i (_i)}
717
+ {#each tableView.rows as row, _i (_i)}
706
718
  {@const record = row.original}
707
719
  {@const id = record[primaryKey] as string | number}
708
720
  <ContextMenu.Root>
@@ -717,8 +729,8 @@
717
729
  onCheckedChange={() => row_toggleSelected(row)}
718
730
  />
719
731
  {:else if cell.column.id === '_expand'}
720
- <TooltipButton tooltip={row_getIsExpanded(row) ? i18n.t('common.collapse') : i18n.t('common.expand')} variant="ghost" size="icon" class="h-7 w-7" onclick={() => row_toggleExpanded(row)}>
721
- {#if row_getIsExpanded(row)}
732
+ <TooltipButton tooltip={rowIsExpanded(row.id) ? i18n.t('common.collapse') : i18n.t('common.expand')} variant="ghost" size="icon" class="h-7 w-7" onclick={() => row_toggleExpanded(row)}>
733
+ {#if rowIsExpanded(row.id)}
722
734
  <ChevronUp class="h-4 w-4" />
723
735
  {:else}
724
736
  <ChevronDown class="h-4 w-4" />
@@ -799,7 +811,7 @@
799
811
  {/if}
800
812
  </ContextMenu.Content>
801
813
  </ContextMenu.Root>
802
- {#if expandedRowRender && row_getIsExpanded(row)}
814
+ {#if expandedRowRender && rowIsExpanded(row.id)}
803
815
  <Table.Row class="bg-muted/10 border-b border-border/10 transition-all">
804
816
  <Table.Cell colspan={row_getVisibleCells(row).length}>
805
817
  {@render expandedRowRender({ record })}
@@ -817,7 +829,7 @@
817
829
  <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" />
818
830
  </svg>
819
831
  <p class="text-sm font-medium text-muted-foreground mb-1">{i18n.t('common.noData')}</p>
820
- <p class="text-xs text-muted-foreground/60 mb-4">{i18n.t('common.noDataHint') || 'Get started by creating your first record.'}</p>
832
+ <p class="text-xs text-muted-foreground/60 mb-4">{i18n.t('common.noDataHint')}</p>
821
833
  {#if canCreate}
822
834
  <Button variant="outline" size="sm" class="gap-2" onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
823
835
  <Plus class="h-3.5 w-3.5" />
@@ -835,7 +847,7 @@
835
847
 
836
848
  <!-- Mobile Card View (visible only on small screens) -->
837
849
  <div class="md:hidden space-y-3 p-2">
838
- {#each table_getRowModel(tbl).rows as row, _i (_i)}
850
+ {#each tableView.rows as row, _i (_i)}
839
851
  {@const record = row.original}
840
852
  {@const id = record[primaryKey] as string | number}
841
853
  <div
@@ -35,11 +35,6 @@
35
35
  const colorTheme = $derived(getColorTheme());
36
36
  const locale = $derived(i18n.locale);
37
37
 
38
- // Auto-trace state changes in dev mode via Svelte 5 $inspect
39
- if ((import.meta as any).env?.DEV) {
40
- // eslint-disable-next-line svelte/no-inspect
41
- $inspect({ path, theme, colorTheme, locale, resourceCount: resources.length });
42
- }
43
38
  </script>
44
39
 
45
40
  <svelte:window onkeydown={handleKeydown} />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.38.5",
3
+ "version": "0.38.7",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -76,6 +76,7 @@
76
76
  "dependencies": {
77
77
  "@floating-ui/dom": "^1.8.0",
78
78
  "@lucide/svelte": "^1.25.0",
79
+ "@tanstack/svelte-store": "^0.12.0",
79
80
  "@tanstack/svelte-table": "9.0.0-alpha.53",
80
81
  "@tanstack/table-core": "9.0.0-alpha.53",
81
82
  "bits-ui": "^2.18.1",