@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.
- package/dist/components/AutoForm.svelte +105 -54
- package/dist/components/AutoForm.svelte.d.ts +4 -0
- package/dist/components/AutoTable.svelte +223 -106
- package/dist/components/AutoTable.svelte.d.ts +5 -0
- package/dist/components/Breadcrumbs.svelte +3 -2
- package/dist/components/Breadcrumbs.svelte.d.ts +1 -0
- package/dist/components/CreatePage.svelte +25 -5
- package/dist/components/CreatePage.svelte.d.ts +3 -0
- package/dist/components/DrawerForm.svelte +1 -1
- package/dist/components/EditPage.svelte +25 -5
- package/dist/components/EditPage.svelte.d.ts +3 -0
- package/dist/components/FieldRenderer.svelte +15 -2
- package/dist/components/FieldRenderer.svelte.d.ts +1 -0
- package/dist/components/ListPage.svelte +29 -3
- package/dist/components/ListPage.svelte.d.ts +9 -0
- package/dist/components/ModalForm.svelte +1 -1
- package/dist/components/PageHeader.svelte +57 -10
- package/dist/components/PageHeader.svelte.d.ts +6 -0
- package/dist/components/ShowPage.svelte +97 -26
- package/dist/components/ShowPage.svelte.d.ts +10 -3
- package/dist/components/buttons/CloneButton.svelte +23 -2
- package/dist/components/buttons/CloneButton.svelte.d.ts +3 -0
- package/dist/components/buttons/CreateButton.svelte +22 -2
- package/dist/components/buttons/CreateButton.svelte.d.ts +3 -0
- package/dist/components/buttons/DeleteButton.svelte +23 -3
- package/dist/components/buttons/DeleteButton.svelte.d.ts +3 -0
- package/dist/components/buttons/EditButton.svelte +23 -2
- package/dist/components/buttons/EditButton.svelte.d.ts +3 -0
- package/dist/components/buttons/ExportButton.svelte +22 -3
- package/dist/components/buttons/ExportButton.svelte.d.ts +3 -0
- package/dist/components/buttons/ImportButton.svelte +39 -17
- package/dist/components/buttons/ImportButton.svelte.d.ts +3 -0
- package/dist/components/buttons/ListButton.svelte +29 -4
- package/dist/components/buttons/ListButton.svelte.d.ts +4 -0
- package/dist/components/buttons/RefreshButton.svelte +22 -2
- package/dist/components/buttons/RefreshButton.svelte.d.ts +3 -0
- package/dist/components/buttons/SaveButton.svelte +23 -2
- package/dist/components/buttons/SaveButton.svelte.d.ts +3 -0
- package/dist/components/buttons/ShowButton.svelte +32 -4
- package/dist/components/buttons/ShowButton.svelte.d.ts +4 -0
- package/dist/components/content/DescriptionList.svelte +125 -15
- package/dist/components/content/DescriptionList.svelte.d.ts +6 -2
- package/dist/components/content/FilterToolbar.svelte +81 -8
- package/dist/components/content/FilterToolbar.svelte.d.ts +7 -1
- package/dist/components/content/StatusTabs.svelte +156 -0
- package/dist/components/content/StatusTabs.svelte.d.ts +18 -0
- package/dist/components/content/index.d.ts +2 -0
- package/dist/components/content/index.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- 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 {
|
|
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=
|
|
153
|
-
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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-
|
|
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-
|
|
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=
|
|
200
|
-
{
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
</
|
|
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-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
<
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
<
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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="
|
|
618
|
+
variant="ghost"
|
|
568
619
|
size="sm"
|
|
569
|
-
class="h-
|
|
570
|
-
onclick={
|
|
620
|
+
class="h-8 text-xs text-muted-foreground hover:text-foreground"
|
|
621
|
+
onclick={() => rowSelectionAtom.set({})}
|
|
571
622
|
>
|
|
572
|
-
|
|
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
|
|
577
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
585
|
-
<SlidersHorizontal class="h-4 w-4"
|
|
586
|
-
</
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
<!--
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
<
|
|
615
|
-
|
|
616
|
-
<
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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={
|
|
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 {
|
|
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))}
|