@svadmin/ui 0.49.3 → 0.51.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.
Files changed (51) hide show
  1. package/dist/components/AutoForm.svelte +105 -54
  2. package/dist/components/AutoForm.svelte.d.ts +4 -0
  3. package/dist/components/AutoTable.svelte +223 -106
  4. package/dist/components/AutoTable.svelte.d.ts +5 -0
  5. package/dist/components/Breadcrumbs.svelte +3 -2
  6. package/dist/components/Breadcrumbs.svelte.d.ts +1 -0
  7. package/dist/components/CreatePage.svelte +25 -5
  8. package/dist/components/CreatePage.svelte.d.ts +3 -0
  9. package/dist/components/DrawerForm.svelte +1 -1
  10. package/dist/components/EditPage.svelte +25 -5
  11. package/dist/components/EditPage.svelte.d.ts +3 -0
  12. package/dist/components/FieldRenderer.svelte +15 -2
  13. package/dist/components/FieldRenderer.svelte.d.ts +1 -0
  14. package/dist/components/ListPage.svelte +29 -3
  15. package/dist/components/ListPage.svelte.d.ts +9 -0
  16. package/dist/components/ModalForm.svelte +1 -1
  17. package/dist/components/PageHeader.svelte +57 -10
  18. package/dist/components/PageHeader.svelte.d.ts +6 -0
  19. package/dist/components/ShowPage.svelte +97 -26
  20. package/dist/components/ShowPage.svelte.d.ts +10 -3
  21. package/dist/components/buttons/CloneButton.svelte +23 -2
  22. package/dist/components/buttons/CloneButton.svelte.d.ts +3 -0
  23. package/dist/components/buttons/CreateButton.svelte +22 -2
  24. package/dist/components/buttons/CreateButton.svelte.d.ts +3 -0
  25. package/dist/components/buttons/DeleteButton.svelte +23 -3
  26. package/dist/components/buttons/DeleteButton.svelte.d.ts +3 -0
  27. package/dist/components/buttons/EditButton.svelte +23 -2
  28. package/dist/components/buttons/EditButton.svelte.d.ts +3 -0
  29. package/dist/components/buttons/ExportButton.svelte +22 -3
  30. package/dist/components/buttons/ExportButton.svelte.d.ts +3 -0
  31. package/dist/components/buttons/ImportButton.svelte +39 -17
  32. package/dist/components/buttons/ImportButton.svelte.d.ts +3 -0
  33. package/dist/components/buttons/ListButton.svelte +29 -4
  34. package/dist/components/buttons/ListButton.svelte.d.ts +4 -0
  35. package/dist/components/buttons/RefreshButton.svelte +22 -2
  36. package/dist/components/buttons/RefreshButton.svelte.d.ts +3 -0
  37. package/dist/components/buttons/SaveButton.svelte +23 -2
  38. package/dist/components/buttons/SaveButton.svelte.d.ts +3 -0
  39. package/dist/components/buttons/ShowButton.svelte +32 -4
  40. package/dist/components/buttons/ShowButton.svelte.d.ts +4 -0
  41. package/dist/components/content/DescriptionList.svelte +125 -15
  42. package/dist/components/content/DescriptionList.svelte.d.ts +6 -2
  43. package/dist/components/content/FilterToolbar.svelte +81 -8
  44. package/dist/components/content/FilterToolbar.svelte.d.ts +7 -1
  45. package/dist/components/content/StatusTabs.svelte +156 -0
  46. package/dist/components/content/StatusTabs.svelte.d.ts +18 -0
  47. package/dist/components/content/index.d.ts +2 -0
  48. package/dist/components/content/index.js +1 -0
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.js +1 -0
  51. package/package.json +2 -2
@@ -13,6 +13,7 @@
13
13
  import * as Alert from './ui/alert/index.js';
14
14
  import { Skeleton } from './ui/skeleton/index.js';
15
15
  import ConfirmDialog from './ConfirmDialog.svelte';
16
+ import { cn } from '../utils.js';
16
17
  import type { Snippet } from 'svelte';
17
18
 
18
19
  const i18n = useTranslation();
@@ -21,15 +22,32 @@
21
22
  resourceName: string;
22
23
  id?: string | number;
23
24
  mode?: 'create' | 'edit' | 'clone' | 'show';
25
+ density?: 'compact' | 'comfortable';
26
+ columns?: 1 | 2 | 3 | 4;
27
+ showHeader?: boolean;
24
28
  fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
25
29
  formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
26
30
  headerContent?: Snippet;
27
31
  onSuccess?: () => void;
32
+ onNavigationGuardReady?: (guard: (fn: () => void) => void) => void;
28
33
  }
29
34
 
30
- let { resourceName, id, mode = 'create', fieldRenderer, formActions, headerContent, onSuccess }: Props = $props();
35
+ let {
36
+ resourceName,
37
+ id,
38
+ mode = 'create',
39
+ density = 'comfortable',
40
+ columns = 1,
41
+ showHeader = true,
42
+ fieldRenderer,
43
+ formActions,
44
+ headerContent,
45
+ onSuccess,
46
+ onNavigationGuardReady,
47
+ }: Props = $props();
31
48
  const adminContext = captureAdminContext();
32
49
  const isReadonly = $derived(mode === 'show');
50
+ const isCompact = $derived(density === 'compact');
33
51
 
34
52
  // ─── Resource metadata ────────────────────────────────────────────
35
53
  const resource = $derived(getResource(resourceName));
@@ -57,6 +75,26 @@
57
75
  return order.map(g => ({ name: g, fields: map.get(g) ?? [] }));
58
76
  })());
59
77
 
78
+ const gridClass = $derived(
79
+ columns === 4
80
+ ? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'
81
+ : columns === 3
82
+ ? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
83
+ : columns === 2
84
+ ? 'grid grid-cols-1 md:grid-cols-2 gap-4'
85
+ : 'space-y-5'
86
+ );
87
+
88
+ function isFullWidthField(field: FieldDefinition): boolean {
89
+ return (
90
+ field.type === 'textarea' ||
91
+ field.type === 'richtext' ||
92
+ field.type === 'json' ||
93
+ field.type === 'array' ||
94
+ field.type === 'images'
95
+ );
96
+ }
97
+
60
98
  // ─── Default values from field metadata ───────────────────────────
61
99
  function getDefaultForType(field: FieldDefinition): unknown {
62
100
  switch (field.type) {
@@ -137,6 +175,10 @@
137
175
  }
138
176
  }
139
177
 
178
+ $effect(() => {
179
+ onNavigationGuardReady?.(guardNavigate);
180
+ });
181
+
140
182
  function confirmNavigate() {
141
183
  confirmOpen = false;
142
184
  pendingNavigation?.();
@@ -149,25 +191,27 @@
149
191
  }
150
192
  </script>
151
193
 
152
- <div class="space-y-6">
153
- <div class="flex items-center gap-4">
154
- <TooltipButton
155
- tooltip={i18n.t('common.back')}
156
- onclick={() => guardNavigate(() => adminContext.navigate(`/${resourceName}`))}
157
- >
158
- <ArrowLeft class="h-5 w-5" />
159
- </TooltipButton>
160
- <h1 class="text-xl font-semibold text-foreground">{pageTitle}</h1>
161
- {#if headerContent}
162
- {@render headerContent()}
163
- {/if}
164
- {#if form.isTainted()}
165
- <Badge variant="outline" class="border-warning/30 bg-warning/10 text-warning-foreground">{i18n.t('common.unsaved')}</Badge>
166
- {/if}
167
- </div>
194
+ <div class={isCompact ? 'space-y-4' : 'space-y-6'}>
195
+ {#if showHeader}
196
+ <div class="flex items-center gap-4">
197
+ <TooltipButton
198
+ tooltip={i18n.t('common.back')}
199
+ onclick={() => guardNavigate(() => adminContext.navigate(`/${resourceName}`))}
200
+ >
201
+ <ArrowLeft class="h-5 w-5" />
202
+ </TooltipButton>
203
+ <h1 class="text-xl font-semibold text-foreground">{pageTitle}</h1>
204
+ {#if headerContent}
205
+ {@render headerContent()}
206
+ {/if}
207
+ {#if form.isTainted()}
208
+ <Badge variant="outline" class="border-warning/30 bg-warning/10 text-warning-foreground">{i18n.t('common.unsaved')}</Badge>
209
+ {/if}
210
+ </div>
211
+ {/if}
168
212
 
169
213
  {#if form.loading}
170
- <div class="max-w-3xl space-y-6">
214
+ <div class="max-w-4xl space-y-6">
171
215
  <div class="rounded-lg shadow-sm ring-1 ring-border/10 p-6 space-y-5">
172
216
  {#each Array(4) as _, _i (_i)}
173
217
  <div class="space-y-2">
@@ -178,7 +222,7 @@
178
222
  </div>
179
223
  </div>
180
224
  {:else}
181
- <form bind:this={formElement} onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-3xl space-y-6" novalidate>
225
+ <form bind:this={formElement} onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-4xl space-y-6" novalidate>
182
226
  {#if submitError}
183
227
  <div transition:slide={{ duration: 300, axis: 'y' }} class="svadmin-shake">
184
228
  <Alert.Root variant="destructive">
@@ -192,21 +236,51 @@
192
236
  {#each groups as group, _i (_i)}
193
237
  <Card.Root class="border-border/40 shadow-sm">
194
238
  {#if group.name}
195
- <Card.Header>
239
+ <Card.Header class={isCompact ? 'px-4 py-3' : ''}>
196
240
  <Card.Title class="text-lg">{group.name}</Card.Title>
197
241
  </Card.Header>
198
242
  {/if}
199
- <Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
200
- {#each group.fields as field (field.key)}
201
- <div class:border-destructive={!!form.errors[field.key]}>
243
+ <Card.Content class={isCompact ? 'px-4 pb-4 pt-0' : 'px-4 sm:px-6 pb-4 sm:pb-6 pt-0'}>
244
+ <div class={gridClass}>
245
+ {#each group.fields as field (field.key)}
246
+ <div class={cn(columns > 1 && isFullWidthField(field) && 'col-span-full', !!form.errors[field.key] && 'border-destructive')}>
247
+ {#if fieldRenderer}
248
+ {@render fieldRenderer({ field, value: form.values[field.key], onchange: (val: unknown) => form.setFieldValue(field.key, val) })}
249
+ {:else}
250
+ <FieldRenderer
251
+ {field}
252
+ value={form.values[field.key]}
253
+ onchange={(val: unknown) => form.setFieldValue(field.key, val)}
254
+ {density}
255
+ invalid={!!form.errors[field.key]}
256
+ errorId={form.errors[field.key] ? fieldErrorId(field.key) : undefined}
257
+ disabled={isReadonly}
258
+ />
259
+ {/if}
260
+ {#if form.errors[field.key]}
261
+ <p id={fieldErrorId(field.key)} class="text-destructive text-[0.8125rem] mt-1" role="alert" aria-live="polite">{form.errors[field.key]}</p>
262
+ {/if}
263
+ </div>
264
+ {/each}
265
+ </div>
266
+ </Card.Content>
267
+ </Card.Root>
268
+ {/each}
269
+ {:else}
270
+ <Card.Root class="border-border/40 shadow-sm">
271
+ <Card.Content class={isCompact ? 'p-4' : 'px-4 pt-5 pb-4 sm:px-6 sm:pt-6 sm:pb-6'}>
272
+ <div class={gridClass} data-svadmin-form-grid data-columns={columns} data-density={density}>
273
+ {#each formFields as field (field.key)}
274
+ <div class={cn(columns > 1 && isFullWidthField(field) && 'col-span-full', !!form.errors[field.key] && 'border-destructive')}>
202
275
  {#if fieldRenderer}
203
276
  {@render fieldRenderer({ field, value: form.values[field.key], onchange: (val: unknown) => form.setFieldValue(field.key, val) })}
204
277
  {:else}
205
- <FieldRenderer
206
- {field}
207
- value={form.values[field.key]}
208
- onchange={(val: unknown) => form.setFieldValue(field.key, val)}
209
- invalid={!!form.errors[field.key]}
278
+ <FieldRenderer
279
+ {field}
280
+ value={form.values[field.key]}
281
+ onchange={(val: unknown) => form.setFieldValue(field.key, val)}
282
+ {density}
283
+ invalid={!!form.errors[field.key]}
210
284
  errorId={form.errors[field.key] ? fieldErrorId(field.key) : undefined}
211
285
  disabled={isReadonly}
212
286
  />
@@ -216,31 +290,7 @@
216
290
  {/if}
217
291
  </div>
218
292
  {/each}
219
- </Card.Content>
220
- </Card.Root>
221
- {/each}
222
- {:else}
223
- <Card.Root class="border-border/40 shadow-sm">
224
- <Card.Content class="space-y-5 px-4 pt-5 pb-4 sm:px-6 sm:pt-6 sm:pb-6">
225
- {#each formFields as field (field.key)}
226
- <div class:border-destructive={!!form.errors[field.key]}>
227
- {#if fieldRenderer}
228
- {@render fieldRenderer({ field, value: form.values[field.key], onchange: (val: unknown) => form.setFieldValue(field.key, val) })}
229
- {:else}
230
- <FieldRenderer
231
- {field}
232
- value={form.values[field.key]}
233
- onchange={(val: unknown) => form.setFieldValue(field.key, val)}
234
- invalid={!!form.errors[field.key]}
235
- errorId={form.errors[field.key] ? fieldErrorId(field.key) : undefined}
236
- disabled={isReadonly}
237
- />
238
- {/if}
239
- {#if form.errors[field.key]}
240
- <p id={fieldErrorId(field.key)} class="text-destructive text-[0.8125rem] mt-1" role="alert" aria-live="polite">{form.errors[field.key]}</p>
241
- {/if}
242
- </div>
243
- {/each}
293
+ </div>
244
294
  </Card.Content>
245
295
  </Card.Root>
246
296
  {/if}
@@ -249,7 +299,7 @@
249
299
  {#if formActions}
250
300
  {@render formActions({ isLoading: form.submitting, onSubmit: handleSubmit })}
251
301
  {:else if !isReadonly}
252
- <Button type="submit" disabled={form.submitting}>
302
+ <Button type="submit" size={isCompact ? 'sm' : 'default'} disabled={form.submitting}>
253
303
  {#if form.submitting}
254
304
  <Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
255
305
  {:else}
@@ -260,6 +310,7 @@
260
310
  <Button
261
311
  type="button"
262
312
  variant="outline"
313
+ size={isCompact ? 'sm' : 'default'}
263
314
  onclick={() => guardNavigate(() => adminContext.navigate(`/${resourceName}`))}
264
315
  >
265
316
  {i18n.t('common.cancel')}
@@ -4,6 +4,9 @@ interface Props {
4
4
  resourceName: string;
5
5
  id?: string | number;
6
6
  mode?: 'create' | 'edit' | 'clone' | 'show';
7
+ density?: 'compact' | 'comfortable';
8
+ columns?: 1 | 2 | 3 | 4;
9
+ showHeader?: boolean;
7
10
  fieldRenderer?: Snippet<[{
8
11
  field: FieldDefinition;
9
12
  value: unknown;
@@ -15,6 +18,7 @@ interface Props {
15
18
  }]>;
16
19
  headerContent?: Snippet;
17
20
  onSuccess?: () => void;
21
+ onNavigationGuardReady?: (guard: (fn: () => void) => void) => void;
18
22
  }
19
23
  declare const AutoForm: import("svelte").Component<Props, {}, "">;
20
24
  type AutoForm = ReturnType<typeof AutoForm>;
@@ -61,7 +61,7 @@
61
61
  import {
62
62
  Plus, Pencil, Trash2,
63
63
  Search, Download, ChevronDown, ChevronUp, SlidersHorizontal, Filter as FilterIcon,
64
- Eye, Copy
64
+ Eye, Copy, RefreshCw, Rows, X
65
65
  } from '@lucide/svelte';
66
66
  import ConfirmDialog from './ConfirmDialog.svelte';
67
67
  import TooltipButton from './TooltipButton.svelte';
@@ -76,6 +76,11 @@
76
76
  resourceName: string;
77
77
  selectable?: boolean;
78
78
  density?: 'compact' | 'comfortable';
79
+ showHeader?: boolean;
80
+ title?: string;
81
+ showFilterTags?: boolean;
82
+ showDensitySwitcher?: boolean;
83
+ showRefresh?: boolean;
79
84
  /** Custom header actions (right side) */
80
85
  headerActions?: Snippet;
81
86
  /** Custom cell renderers by field key */
@@ -100,6 +105,11 @@
100
105
  resourceName,
101
106
  selectable = true,
102
107
  density = 'comfortable',
108
+ showHeader = true,
109
+ title,
110
+ showFilterTags = true,
111
+ showDensitySwitcher = true,
112
+ showRefresh = true,
103
113
  headerActions,
104
114
  columns: customColumns,
105
115
  defaultCellRenderer,
@@ -110,6 +120,9 @@
110
120
  pagination: externalPagination,
111
121
  sorters: externalSorters,
112
122
  }: Props = $props();
123
+
124
+ let densityOverride = $state<'compact' | 'comfortable' | undefined>(undefined);
125
+ const currentDensity = $derived(densityOverride ?? density);
113
126
  const adminContext = captureAdminContext();
114
127
 
115
128
  const resource = $derived(getResource(resourceName));
@@ -206,6 +219,12 @@
206
219
  return result;
207
220
  });
208
221
 
222
+ function removeFilterKey(key: string): void {
223
+ const { [key]: _removed, ...rest } = filterValues;
224
+ filterValues = rest;
225
+ pagination = { ...pagination, current: 1 };
226
+ }
227
+
209
228
  function clonePlainValue(value: unknown): unknown {
210
229
  if (Array.isArray(value)) return value.map(clonePlainValue);
211
230
  if (value && typeof value === 'object') {
@@ -552,38 +571,173 @@
552
571
  }
553
572
  </script>
554
573
 
555
- <div class="space-y-4">
556
- <!-- Header -->
557
- <div class="flex flex-wrap items-center justify-between gap-2">
558
- <h1 class="text-lg sm:text-xl font-semibold text-foreground">{resource.label}</h1>
559
- <div class="flex flex-wrap items-center gap-2">
560
- {#if canExport}
561
- <Button variant="outline" size="sm" onclick={exportCSV}>
562
- <Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.export')}
563
- </Button>
564
- {/if}
565
- {#if selectedCount > 0 && canDelete}
574
+ <div class="space-y-3">
575
+ {#if showHeader}
576
+ <!-- Header -->
577
+ <div class="flex flex-wrap items-center justify-between gap-2">
578
+ <h1 class="text-lg sm:text-xl font-semibold text-foreground">{title ?? resource.label}</h1>
579
+ <div class="flex flex-wrap items-center gap-2">
580
+ {#if canExport}
581
+ <Button variant="outline" size="sm" onclick={exportCSV}>
582
+ <Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.export')}
583
+ </Button>
584
+ {/if}
585
+ {#if headerActions}
586
+ {@render headerActions()}
587
+ {/if}
588
+ {#if canCreate}
589
+ <Button onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
590
+ <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.create')}
591
+ </Button>
592
+ {/if}
593
+ </div>
594
+ </div>
595
+ {/if}
596
+
597
+ <!-- Selection Banner (Enterprise Batch Actions) -->
598
+ {#if selectedCount > 0}
599
+ <div class="flex flex-wrap items-center justify-between gap-3 px-3.5 py-2 bg-primary/10 border border-primary/20 rounded-lg text-sm text-foreground animate-in fade-in duration-200">
600
+ <div class="flex items-center gap-2">
601
+ <span class="font-medium text-xs sm:text-sm">{i18n.t('common.selectedItems', { count: selectedCount })}</span>
602
+ </div>
603
+ <div class="flex items-center gap-2">
604
+ {#if canDelete}
605
+ <Button
606
+ variant="destructive"
607
+ size="sm"
608
+ class="h-8 text-xs whitespace-nowrap"
609
+ onclick={confirmBatchDelete}
610
+ >
611
+ <Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t('common.batchDelete', { count: selectedCount })}
612
+ </Button>
613
+ {/if}
614
+ {#if batchActions}
615
+ {@render batchActions({ selectedIds: Object.keys(tableRowSelection.current) })}
616
+ {/if}
566
617
  <Button
567
- variant="destructive"
618
+ variant="ghost"
568
619
  size="sm"
569
- class="h-9 whitespace-nowrap"
570
- onclick={confirmBatchDelete}
620
+ class="h-8 text-xs text-muted-foreground hover:text-foreground"
621
+ onclick={() => rowSelectionAtom.set({})}
571
622
  >
572
- <Trash2 class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.batchDelete', { count: selectedCount })}
623
+ {i18n.t('common.clearSelection')}
573
624
  </Button>
625
+ </div>
626
+ </div>
627
+ {/if}
628
+
629
+ <!-- Search, Filter & Table Utility Toolbar -->
630
+ <div class="flex flex-wrap items-center justify-between gap-2">
631
+ <!-- Left: Search and Advanced Filters -->
632
+ <div class="flex flex-wrap items-center gap-2 flex-1 min-w-[200px]">
633
+ {#if searchableFields.length > 0}
634
+ <div class="relative max-w-sm flex-1 sm:min-w-[220px]">
635
+ <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
636
+ <Input
637
+ type="text"
638
+ value={searchText}
639
+ oninput={scheduleSearch}
640
+ placeholder={i18n.t('common.search')}
641
+ class="pl-9 h-9 text-sm"
642
+ />
643
+ </div>
574
644
  {/if}
575
-
576
- {#if selectedCount > 0 && batchActions}
577
- {@render batchActions({ selectedIds: Object.keys(tableRowSelection.current) })}
645
+
646
+ {#if filterableFields.length > 0}
647
+ <Popover.Root>
648
+ <Popover.Trigger>
649
+ {#snippet child({ props })}
650
+ <Button variant="outline" size="sm" class="h-9 px-3" {...props}>
651
+ <FilterIcon class="h-4 w-4" data-icon="inline-start" />
652
+ {i18n.t('common.filter')}
653
+ {#if activeFilterCount > 0}
654
+ <Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
655
+ {/if}
656
+ </Button>
657
+ {/snippet}
658
+ </Popover.Trigger>
659
+ <Popover.Content class="w-80">
660
+ <div class="space-y-3">
661
+ <h4 class="font-medium text-sm">{i18n.t('common.filter')}</h4>
662
+ {#each filterableFields as field, _i (_i)}
663
+ <div class="space-y-1">
664
+ <label class="text-xs text-muted-foreground" for="filter-{field.key}">{field.label}</label>
665
+ {#if field.type === 'select' && field.options}
666
+ <select
667
+ id="filter-{field.key}"
668
+ class="h-9 text-sm w-full font-normal flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
669
+ value={filterValues[field.key] ?? ''}
670
+ onchange={(e) => filterValues[field.key] = (e.currentTarget as HTMLSelectElement).value}
671
+ >
672
+ <option value="">{i18n.t('common.all')}</option>
673
+ {#each field.options as opt, _i (_i)}
674
+ <option value={opt.value}>{opt.label}</option>
675
+ {/each}
676
+ </select>
677
+ {:else}
678
+ <Input
679
+ id="filter-{field.key}"
680
+ type="text"
681
+ value={filterValues[field.key] ?? ''}
682
+ oninput={(e) => filterValues[field.key] = (e.currentTarget as HTMLInputElement).value}
683
+ placeholder={field.label}
684
+ class="h-9 text-sm"
685
+ />
686
+ {/if}
687
+ </div>
688
+ {/each}
689
+ <div class="flex gap-2 pt-2">
690
+ <Button size="sm" class="flex-1" onclick={() => { pagination = { ...pagination, current: 1 }; }}>
691
+ {i18n.t('common.confirm')}
692
+ </Button>
693
+ <Button variant="outline" size="sm" onclick={() => {
694
+ filterValues = {};
695
+ pagination = { ...pagination, current: 1 };
696
+ }}>
697
+ {i18n.t('common.reset')}
698
+ </Button>
699
+ </div>
700
+ </div>
701
+ </Popover.Content>
702
+ </Popover.Root>
578
703
  {/if}
704
+ </div>
579
705
 
580
- <!-- Column Visibility Picker (DropdownMenu) -->
706
+ <!-- Right: Table Controls (Density, Columns, Refresh, Export if !showHeader) -->
707
+ <div class="flex items-center gap-1.5 shrink-0">
708
+ {#if !showHeader && canExport}
709
+ <TooltipButton tooltip={i18n.t('common.export')} variant="outline" size="sm" class="h-9 px-2.5" onclick={exportCSV}>
710
+ <Download class="h-4 w-4" />
711
+ </TooltipButton>
712
+ {/if}
713
+
714
+ {#if showDensitySwitcher}
715
+ <DropdownMenu.Root>
716
+ <DropdownMenu.Trigger>
717
+ {#snippet child({ props })}
718
+ <TooltipButton tooltip={i18n.t('common.density')} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
719
+ <Rows class="h-4 w-4" />
720
+ </TooltipButton>
721
+ {/snippet}
722
+ </DropdownMenu.Trigger>
723
+ <DropdownMenu.Content align="end" class="w-36">
724
+ <DropdownMenu.Item onclick={() => { densityOverride = 'comfortable'; }} class={currentDensity === 'comfortable' ? 'font-semibold text-primary' : ''}>
725
+ {i18n.t('common.densityComfortable')}
726
+ </DropdownMenu.Item>
727
+ <DropdownMenu.Item onclick={() => { densityOverride = 'compact'; }} class={currentDensity === 'compact' ? 'font-semibold text-primary' : ''}>
728
+ {i18n.t('common.densityCompact')}
729
+ </DropdownMenu.Item>
730
+ </DropdownMenu.Content>
731
+ </DropdownMenu.Root>
732
+ {/if}
733
+
734
+ <!-- Column Visibility Picker -->
581
735
  <DropdownMenu.Root>
582
736
  <DropdownMenu.Trigger>
583
737
  {#snippet child({ props })}
584
- <Button variant="outline" size="sm" {...props}>
585
- <SlidersHorizontal class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.columns')}
586
- </Button>
738
+ <TooltipButton tooltip={i18n.t('common.columns')} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
739
+ <SlidersHorizontal class="h-4 w-4" />
740
+ </TooltipButton>
587
741
  {/snippet}
588
742
  </DropdownMenu.Trigger>
589
743
  <DropdownMenu.Content align="end" class="w-48">
@@ -597,94 +751,57 @@
597
751
  {/each}
598
752
  </DropdownMenu.Content>
599
753
  </DropdownMenu.Root>
600
- {#if headerActions}
601
- {@render headerActions()}
602
- {/if}
603
- {#if canCreate}
604
- <Button onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
605
- <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.create')}
606
- </Button>
754
+
755
+ {#if showRefresh}
756
+ <TooltipButton
757
+ tooltip={i18n.t('common.refresh')}
758
+ variant="outline"
759
+ size="sm"
760
+ class="h-9 px-2.5"
761
+ onclick={() => listResult.refetch()}
762
+ >
763
+ <RefreshCw class="h-4 w-4 {query.isFetching ? 'animate-spin' : ''}" />
764
+ </TooltipButton>
607
765
  {/if}
608
766
  </div>
609
767
  </div>
610
768
 
611
- <!-- Search and Advanced Filters -->
612
- <div class="flex flex-wrap items-center gap-2">
613
- {#if searchableFields.length > 0}
614
- <div class="relative max-w-sm flex-1 sm:min-w-[250px]">
615
- <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
616
- <Input
617
- type="text"
618
- value={searchText}
619
- oninput={scheduleSearch}
620
- placeholder={i18n.t('common.search')}
621
- class="pl-10 h-9"
622
- />
623
- </div>
624
- {/if}
625
-
626
- {#if filterableFields.length > 0}
627
- <Popover.Root>
628
- <Popover.Trigger>
629
- {#snippet child({ props })}
630
- <Button variant="outline" size="sm" class="h-9 px-3" {...props}>
631
- <FilterIcon class="h-4 w-4" data-icon="inline-start" />
632
- {i18n.t('common.filter')}
633
- {#if activeFilterCount > 0}
634
- <Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
635
- {/if}
636
- </Button>
637
- {/snippet}
638
- </Popover.Trigger>
639
- <Popover.Content class="w-80">
640
- <div class="space-y-3">
641
- <h4 class="font-medium text-sm">{i18n.t('common.filter')}</h4>
642
- {#each filterableFields as field, _i (_i)}
643
- <div class="space-y-1">
644
- <label class="text-xs text-muted-foreground" for="filter-{field.key}">{field.label}</label>
645
- {#if field.type === 'select' && field.options}
646
- <select
647
- id="filter-{field.key}"
648
- class="h-9 text-sm w-full font-normal flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
649
- value={filterValues[field.key] ?? ''}
650
- onchange={(e) => filterValues[field.key] = (e.currentTarget as HTMLSelectElement).value}
651
- >
652
- <option value="">{i18n.t('common.all')}</option>
653
- {#each field.options as opt, _i (_i)}
654
- <option value={opt.value}>{opt.label}</option>
655
- {/each}
656
- </select>
657
- {:else}
658
- <Input
659
- id="filter-{field.key}"
660
- type="text"
661
- value={filterValues[field.key] ?? ''}
662
- oninput={(e) => filterValues[field.key] = (e.currentTarget as HTMLInputElement).value}
663
- placeholder={field.label}
664
- class="h-9 text-sm"
665
- />
666
- {/if}
667
- </div>
668
- {/each}
669
- <div class="flex gap-2 pt-2">
670
- <Button size="sm" class="flex-1" onclick={() => { pagination = { ...pagination, current: 1 }; }}>
671
- {i18n.t('common.confirm')}
672
- </Button>
673
- <Button variant="outline" size="sm" onclick={() => {
674
- filterValues = {};
675
- pagination = { ...pagination, current: 1 };
676
- }}>
677
- {i18n.t('common.reset')}
678
- </Button>
679
- </div>
680
- </div>
681
- </Popover.Content>
682
- </Popover.Root>
683
- {/if}
684
- </div>
769
+ <!-- Active Filter Tags -->
770
+ {#if showFilterTags && (appliedSearchText.trim() || activeFilterCount > 0)}
771
+ <div class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground pt-0.5">
772
+ <span class="font-medium text-foreground">{i18n.t('common.filterTags')}:</span>
773
+ {#if appliedSearchText.trim()}
774
+ <Badge variant="secondary" class="gap-1 pr-1 font-normal text-xs">
775
+ <span>{i18n.t('common.search')}: "{appliedSearchText}"</span>
776
+ <button type="button" class="hover:text-foreground cursor-pointer" aria-label={`${i18n.t('common.clear')}: ${i18n.t('common.search')}`} onclick={() => { searchText = ''; appliedSearchText = ''; pagination = { ...pagination, current: 1 }; }}>
777
+ <X class="h-3 w-3" />
778
+ </button>
779
+ </Badge>
780
+ {/if}
781
+ {#each Object.entries(filterValues) as [key, val] (key)}
782
+ {#if val.trim()}
783
+ {@const field = filterableFields.find(f => f.key === key)}
784
+ <Badge variant="secondary" class="gap-1 pr-1 font-normal text-xs">
785
+ <span>{field?.label ?? key}: {val}</span>
786
+ <button type="button" class="hover:text-foreground cursor-pointer" aria-label={`${i18n.t('common.clear')}: ${field?.label ?? key}`} onclick={() => removeFilterKey(key)}>
787
+ <X class="h-3 w-3" />
788
+ </button>
789
+ </Badge>
790
+ {/if}
791
+ {/each}
792
+ <Button variant="ghost" size="sm" class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" onclick={() => {
793
+ searchText = '';
794
+ appliedSearchText = '';
795
+ filterValues = {};
796
+ pagination = { ...pagination, current: 1 };
797
+ }}>
798
+ {i18n.t('common.clearAllFilters')}
799
+ </Button>
800
+ </div>
801
+ {/if}
685
802
 
686
803
  <!-- Table (TanStack-powered) -->
687
- <div class="overflow-hidden rounded-lg border border-border bg-card shadow-sm" role="region" aria-label="{resource.label} {i18n.t('common.list')}" data-table-density={density}>
804
+ <div class="overflow-hidden rounded-lg border border-border bg-card shadow-sm" role="region" aria-label="{resource.label} {i18n.t('common.list')}" data-table-density={currentDensity}>
688
805
  {#if query.isLoading}
689
806
  <div class="p-4 space-y-3">
690
807
  <div class="flex gap-4 mb-2">
@@ -708,7 +825,7 @@
708
825
  <div in:fade={{ duration: 150 }}>
709
826
  <!-- Desktop Table (hidden on mobile) -->
710
827
  <div class="hidden md:block">
711
- <Table.Root {density}>
828
+ <Table.Root density={currentDensity}>
712
829
  <Table.Header>
713
830
  {#each tableView.headerGroups as headerGroup, _i (_i)}
714
831
  {@const visibleHeaders = headerGroup.headers.filter((header: Header<TableFeatures, BaseRecord, unknown>) => isColumnVisible(header.column.id))}