@svadmin/ui 0.49.2 → 0.50.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/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 +15 -3
- package/dist/components/ListPage.svelte.d.ts +3 -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 +37 -8
- package/dist/components/ShowPage.svelte.d.ts +7 -3
- package/dist/components/buttons/ListButton.svelte +18 -4
- package/dist/components/buttons/ListButton.svelte.d.ts +2 -0
- package/dist/components/buttons/ShowButton.svelte +9 -2
- package/dist/components/buttons/ShowButton.svelte.d.ts +1 -0
- 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 +1 -1
|
@@ -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>;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { captureAdminContext, getResources, type MenuItem } from '@svadmin/core';
|
|
3
3
|
import { getPath } from '../router-state.svelte.js';
|
|
4
4
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
5
|
+
import { cn } from '../utils.js';
|
|
5
6
|
|
|
6
7
|
import * as Breadcrumb from './ui/breadcrumb/index.js';
|
|
7
8
|
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
const adminContext = captureAdminContext();
|
|
11
12
|
const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
|
|
12
13
|
|
|
13
|
-
let { menu }: { menu?: MenuItem[] } = $props();
|
|
14
|
+
let { menu, class: className = 'mb-4' }: { menu?: MenuItem[]; class?: string } = $props();
|
|
14
15
|
|
|
15
16
|
interface Crumb { label: string; href?: string; }
|
|
16
17
|
|
|
@@ -83,7 +84,7 @@
|
|
|
83
84
|
</script>
|
|
84
85
|
|
|
85
86
|
{#if crumbs.length > 1}
|
|
86
|
-
<Breadcrumb.Root class=
|
|
87
|
+
<Breadcrumb.Root class={cn(className)}>
|
|
87
88
|
<Breadcrumb.List>
|
|
88
89
|
{#each crumbs as crumb, i (`${crumb.label}-${i}`)}
|
|
89
90
|
{#if i > 0}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { getResource } from '@svadmin/core';
|
|
2
|
+
import { getResource, captureAdminContext } from '@svadmin/core';
|
|
3
3
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
4
|
import type { Snippet } from 'svelte';
|
|
5
5
|
import PageHeader from './PageHeader.svelte';
|
|
@@ -11,30 +11,50 @@
|
|
|
11
11
|
interface Props {
|
|
12
12
|
resourceName: string;
|
|
13
13
|
title?: string;
|
|
14
|
+
density?: 'compact' | 'comfortable';
|
|
15
|
+
columns?: 1 | 2 | 3 | 4;
|
|
14
16
|
headerActions?: Snippet;
|
|
17
|
+
onSuccess?: () => void;
|
|
15
18
|
class?: string;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
let {
|
|
19
22
|
resourceName,
|
|
20
23
|
title,
|
|
24
|
+
density = 'comfortable',
|
|
25
|
+
columns = 1,
|
|
21
26
|
headerActions,
|
|
27
|
+
onSuccess,
|
|
22
28
|
class: className = '',
|
|
23
29
|
}: Props = $props();
|
|
30
|
+
const adminContext = captureAdminContext();
|
|
31
|
+
let navigateGuard = $state<(fn: () => void) => void>((fn) => fn());
|
|
24
32
|
|
|
25
33
|
const resource = $derived(getResource(resourceName));
|
|
26
34
|
const pageTitle = $derived(title ?? `${i18n.t('common.create')}${resource.label}`);
|
|
27
35
|
</script>
|
|
28
36
|
|
|
29
|
-
<div class="space-y-6 {className}">
|
|
30
|
-
<PageHeader
|
|
37
|
+
<div class="{density === 'compact' ? 'space-y-4' : 'space-y-6'} {className}">
|
|
38
|
+
<PageHeader
|
|
39
|
+
title={pageTitle}
|
|
40
|
+
{density}
|
|
41
|
+
onBack={() => navigateGuard(() => adminContext.navigate(`/${resourceName}`))}
|
|
42
|
+
>
|
|
31
43
|
{#snippet actions()}
|
|
32
|
-
<ListButton resource={resourceName} hideText />
|
|
44
|
+
<ListButton resource={resourceName} hideText onBeforeNavigate={navigateGuard} />
|
|
33
45
|
{#if headerActions}
|
|
34
46
|
{@render headerActions()}
|
|
35
47
|
{/if}
|
|
36
48
|
{/snippet}
|
|
37
49
|
</PageHeader>
|
|
38
50
|
|
|
39
|
-
<AutoForm
|
|
51
|
+
<AutoForm
|
|
52
|
+
{resourceName}
|
|
53
|
+
mode="create"
|
|
54
|
+
{density}
|
|
55
|
+
{columns}
|
|
56
|
+
showHeader={false}
|
|
57
|
+
onNavigationGuardReady={(guard) => navigateGuard = guard}
|
|
58
|
+
{onSuccess}
|
|
59
|
+
/>
|
|
40
60
|
</div>
|
|
@@ -2,7 +2,10 @@ import type { Snippet } from 'svelte';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
resourceName: string;
|
|
4
4
|
title?: string;
|
|
5
|
+
density?: 'compact' | 'comfortable';
|
|
6
|
+
columns?: 1 | 2 | 3 | 4;
|
|
5
7
|
headerActions?: Snippet;
|
|
8
|
+
onSuccess?: () => void;
|
|
6
9
|
class?: string;
|
|
7
10
|
}
|
|
8
11
|
declare const CreatePage: import("svelte").Component<Props, {}, "">;
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
</Sheet.Title>
|
|
33
33
|
</Sheet.Header>
|
|
34
34
|
<div class="flex-1 overflow-y-auto">
|
|
35
|
-
<AutoForm {resourceName} {mode} {id} onSuccess={handleSuccess} />
|
|
35
|
+
<AutoForm {resourceName} {mode} {id} showHeader={false} onSuccess={handleSuccess} />
|
|
36
36
|
</div>
|
|
37
37
|
</Sheet.Content>
|
|
38
38
|
</Sheet.Root>
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
resourceName: string;
|
|
17
17
|
id: string | number;
|
|
18
18
|
title?: string;
|
|
19
|
+
density?: 'compact' | 'comfortable';
|
|
20
|
+
columns?: 1 | 2 | 3 | 4;
|
|
19
21
|
canDelete?: boolean;
|
|
20
22
|
headerActions?: Snippet;
|
|
23
|
+
onSuccess?: () => void;
|
|
21
24
|
class?: string;
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -25,22 +28,30 @@
|
|
|
25
28
|
resourceName,
|
|
26
29
|
id,
|
|
27
30
|
title,
|
|
31
|
+
density = 'comfortable',
|
|
32
|
+
columns = 1,
|
|
28
33
|
canDelete,
|
|
29
34
|
headerActions,
|
|
35
|
+
onSuccess,
|
|
30
36
|
class: className = '',
|
|
31
37
|
}: Props = $props();
|
|
32
38
|
const adminContext = captureAdminContext();
|
|
39
|
+
let navigateGuard = $state<(fn: () => void) => void>((fn) => fn());
|
|
33
40
|
|
|
34
41
|
const resource = $derived(getResource(resourceName));
|
|
35
42
|
const pageTitle = $derived(title ?? `${i18n.t('common.edit')} ${resource.label} #${id}`);
|
|
36
43
|
const showDelete = $derived(canDelete ?? resource.canDelete !== false);
|
|
37
44
|
</script>
|
|
38
45
|
|
|
39
|
-
<div class="space-y-6 {className}">
|
|
40
|
-
<PageHeader
|
|
46
|
+
<div class="{density === 'compact' ? 'space-y-4' : 'space-y-6'} {className}">
|
|
47
|
+
<PageHeader
|
|
48
|
+
title={pageTitle}
|
|
49
|
+
{density}
|
|
50
|
+
onBack={() => navigateGuard(() => adminContext.navigate(`/${resourceName}`))}
|
|
51
|
+
>
|
|
41
52
|
{#snippet actions()}
|
|
42
|
-
<ListButton resource={resourceName} hideText />
|
|
43
|
-
<ShowButton resource={resourceName} recordItemId={id} hideText />
|
|
53
|
+
<ListButton resource={resourceName} hideText onBeforeNavigate={navigateGuard} />
|
|
54
|
+
<ShowButton resource={resourceName} recordItemId={id} hideText onBeforeNavigate={navigateGuard} />
|
|
44
55
|
<RefreshButton resource={resourceName} hideText />
|
|
45
56
|
{#if showDelete !== false}
|
|
46
57
|
<DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => adminContext.navigate(`/${resourceName}`)} />
|
|
@@ -51,5 +62,14 @@
|
|
|
51
62
|
{/snippet}
|
|
52
63
|
</PageHeader>
|
|
53
64
|
|
|
54
|
-
<AutoForm
|
|
65
|
+
<AutoForm
|
|
66
|
+
{resourceName}
|
|
67
|
+
mode="edit"
|
|
68
|
+
{id}
|
|
69
|
+
{density}
|
|
70
|
+
{columns}
|
|
71
|
+
showHeader={false}
|
|
72
|
+
onNavigationGuardReady={(guard) => navigateGuard = guard}
|
|
73
|
+
{onSuccess}
|
|
74
|
+
/>
|
|
55
75
|
</div>
|
|
@@ -3,8 +3,11 @@ interface Props {
|
|
|
3
3
|
resourceName: string;
|
|
4
4
|
id: string | number;
|
|
5
5
|
title?: string;
|
|
6
|
+
density?: 'compact' | 'comfortable';
|
|
7
|
+
columns?: 1 | 2 | 3 | 4;
|
|
6
8
|
canDelete?: boolean;
|
|
7
9
|
headerActions?: Snippet;
|
|
10
|
+
onSuccess?: () => void;
|
|
8
11
|
class?: string;
|
|
9
12
|
}
|
|
10
13
|
declare const EditPage: import("svelte").Component<Props, {}, "">;
|
|
@@ -20,19 +20,23 @@
|
|
|
20
20
|
import { Plus, X } from '@lucide/svelte';
|
|
21
21
|
import type { Snippet } from 'svelte';
|
|
22
22
|
import { getRichTextEditor } from '../editor-config.svelte.js';
|
|
23
|
+
import { cn } from '../utils.js';
|
|
23
24
|
|
|
24
25
|
const i18n = useTranslation();
|
|
25
26
|
|
|
26
|
-
let { field, value, onchange, disabled, invalid = false, errorId, children } = $props<{
|
|
27
|
+
let { field, value, onchange, disabled, invalid = false, errorId, density = 'comfortable', children } = $props<{
|
|
27
28
|
field: FieldDefinition;
|
|
28
29
|
value: unknown;
|
|
29
30
|
onchange: (val: unknown) => void;
|
|
30
31
|
disabled?: boolean;
|
|
31
32
|
invalid?: boolean;
|
|
32
33
|
errorId?: string;
|
|
34
|
+
density?: 'compact' | 'comfortable';
|
|
33
35
|
children?: Snippet;
|
|
34
36
|
}>();
|
|
35
37
|
|
|
38
|
+
const isCompact = $derived(density === 'compact');
|
|
39
|
+
|
|
36
40
|
// Typed accessors
|
|
37
41
|
const strVal = $derived((value as string) ?? '');
|
|
38
42
|
const numVal = $derived(value as number | null | undefined);
|
|
@@ -89,7 +93,15 @@
|
|
|
89
93
|
}
|
|
90
94
|
</script>
|
|
91
95
|
|
|
92
|
-
<div
|
|
96
|
+
<div
|
|
97
|
+
class={cn(
|
|
98
|
+
'space-y-1.5',
|
|
99
|
+
isCompact && 'space-y-1 [&_[data-slot=input]]:h-8 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:py-1 [&_[data-slot=input]]:text-xs [&_[data-slot=select]]:h-8 [&_[data-slot=select]]:px-2.5 [&_[data-slot=select]]:py-1 [&_[data-slot=select]]:text-xs [&_[data-slot=textarea]]:min-h-16 [&_[data-slot=textarea]]:px-2.5 [&_[data-slot=textarea]]:py-1.5 [&_[data-slot=textarea]]:text-xs [&_[data-slot=button]]:h-8 [&_[data-slot=button]]:text-xs'
|
|
100
|
+
)}
|
|
101
|
+
data-svadmin-field
|
|
102
|
+
data-svadmin-field-key={field.key}
|
|
103
|
+
data-density={density}
|
|
104
|
+
>
|
|
93
105
|
<Label for={field.key} id="label-{field.key}" data-svadmin-field-label>
|
|
94
106
|
{field.label}
|
|
95
107
|
{#if field.required}
|
|
@@ -270,6 +282,7 @@
|
|
|
270
282
|
<select
|
|
271
283
|
id={field.key}
|
|
272
284
|
name={field.key}
|
|
285
|
+
data-slot="select"
|
|
273
286
|
class="flex h-10 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"
|
|
274
287
|
value={strVal}
|
|
275
288
|
onchange={(e) => onchange((e.target as HTMLSelectElement).value)}
|
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
resourceName: string;
|
|
16
16
|
title?: string;
|
|
17
17
|
canCreate?: boolean;
|
|
18
|
+
density?: 'compact' | 'comfortable';
|
|
18
19
|
/** Passthrough: enable row selection checkboxes */
|
|
19
20
|
selectable?: boolean;
|
|
20
21
|
/** Passthrough: custom header actions */
|
|
21
22
|
headerActions?: Snippet;
|
|
23
|
+
/** Optional status tabs or filter slot above table */
|
|
24
|
+
statusTabs?: Snippet;
|
|
22
25
|
/** Passthrough: custom cell renderer per field */
|
|
23
26
|
cellRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; record: Record<string, unknown> }]>;
|
|
24
27
|
/** Passthrough: custom row actions */
|
|
@@ -34,8 +37,10 @@
|
|
|
34
37
|
resourceName,
|
|
35
38
|
title,
|
|
36
39
|
canCreate,
|
|
40
|
+
density = 'comfortable',
|
|
37
41
|
selectable,
|
|
38
42
|
headerActions,
|
|
43
|
+
statusTabs,
|
|
39
44
|
cellRenderer,
|
|
40
45
|
rowActions,
|
|
41
46
|
emptyState,
|
|
@@ -49,11 +54,11 @@
|
|
|
49
54
|
const showCreate = $derived(canCreate ?? resource.canCreate !== false);
|
|
50
55
|
</script>
|
|
51
56
|
|
|
52
|
-
<div class="space-y-6 {className}">
|
|
53
|
-
<PageHeader title={pageTitle}>
|
|
57
|
+
<div class="{density === 'compact' ? 'space-y-4' : 'space-y-6'} {className}">
|
|
58
|
+
<PageHeader title={pageTitle} {density}>
|
|
54
59
|
{#snippet actions()}
|
|
55
60
|
{#if showCreate}
|
|
56
|
-
<Button onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
|
|
61
|
+
<Button size={density === 'compact' ? 'sm' : 'default'} onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
|
|
57
62
|
<Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.create')}
|
|
58
63
|
</Button>
|
|
59
64
|
{/if}
|
|
@@ -63,8 +68,15 @@
|
|
|
63
68
|
{/snippet}
|
|
64
69
|
</PageHeader>
|
|
65
70
|
|
|
71
|
+
{#if statusTabs}
|
|
72
|
+
<div>
|
|
73
|
+
{@render statusTabs()}
|
|
74
|
+
</div>
|
|
75
|
+
{/if}
|
|
76
|
+
|
|
66
77
|
<AutoTable
|
|
67
78
|
{resourceName}
|
|
79
|
+
{density}
|
|
68
80
|
{selectable}
|
|
69
81
|
defaultCellRenderer={cellRenderer}
|
|
70
82
|
{rowActions}
|
|
@@ -4,10 +4,13 @@ interface Props {
|
|
|
4
4
|
resourceName: string;
|
|
5
5
|
title?: string;
|
|
6
6
|
canCreate?: boolean;
|
|
7
|
+
density?: 'compact' | 'comfortable';
|
|
7
8
|
/** Passthrough: enable row selection checkboxes */
|
|
8
9
|
selectable?: boolean;
|
|
9
10
|
/** Passthrough: custom header actions */
|
|
10
11
|
headerActions?: Snippet;
|
|
12
|
+
/** Optional status tabs or filter slot above table */
|
|
13
|
+
statusTabs?: Snippet;
|
|
11
14
|
/** Passthrough: custom cell renderer per field */
|
|
12
15
|
cellRenderer?: Snippet<[{
|
|
13
16
|
field: FieldDefinition;
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
{mode === 'create' ? `${i18n.t('common.create')}${resource.label}` : `${i18n.t('common.edit')}${resource.label}`}
|
|
33
33
|
</Dialog.DialogTitle>
|
|
34
34
|
</Dialog.DialogHeader>
|
|
35
|
-
<AutoForm {resourceName} {mode} {id} onSuccess={handleSuccess} />
|
|
35
|
+
<AutoForm {resourceName} {mode} {id} showHeader={false} onSuccess={handleSuccess} />
|
|
36
36
|
</Dialog.DialogContent>
|
|
37
37
|
</Dialog.Dialog>
|
|
38
38
|
{/if}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { ArrowLeft } from '@lucide/svelte';
|
|
3
4
|
import Breadcrumbs from './Breadcrumbs.svelte';
|
|
5
|
+
import { Button } from './ui/button/index.js';
|
|
6
|
+
import { cn } from '../utils.js';
|
|
4
7
|
|
|
5
8
|
interface Props {
|
|
6
9
|
title: string;
|
|
7
10
|
description?: string;
|
|
8
11
|
actions?: Snippet;
|
|
12
|
+
extra?: Snippet;
|
|
13
|
+
tags?: Snippet;
|
|
14
|
+
back?: Snippet;
|
|
15
|
+
onBack?: () => void;
|
|
16
|
+
backLabel?: string;
|
|
9
17
|
showBreadcrumbs?: boolean;
|
|
18
|
+
density?: 'compact' | 'comfortable';
|
|
10
19
|
class?: string;
|
|
11
20
|
}
|
|
12
21
|
|
|
@@ -14,25 +23,63 @@
|
|
|
14
23
|
title,
|
|
15
24
|
description,
|
|
16
25
|
actions,
|
|
26
|
+
extra,
|
|
27
|
+
tags,
|
|
28
|
+
back,
|
|
29
|
+
onBack,
|
|
30
|
+
backLabel = 'Back',
|
|
17
31
|
showBreadcrumbs = true,
|
|
32
|
+
density = 'comfortable',
|
|
18
33
|
class: className = '',
|
|
19
34
|
}: Props = $props();
|
|
35
|
+
|
|
36
|
+
const isCompact = $derived(density === 'compact');
|
|
20
37
|
</script>
|
|
21
38
|
|
|
22
|
-
<div class=
|
|
39
|
+
<div class={cn(isCompact ? 'space-y-1.5' : 'space-y-4', className)}>
|
|
23
40
|
{#if showBreadcrumbs}
|
|
24
|
-
<Breadcrumbs />
|
|
41
|
+
<Breadcrumbs class={isCompact ? 'mb-1 text-xs' : 'mb-4'} />
|
|
25
42
|
{/if}
|
|
26
|
-
<div class=
|
|
27
|
-
<div>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
<div class={cn('flex flex-col sm:flex-row sm:items-center sm:justify-between', isCompact ? 'gap-1.5' : 'gap-2')}>
|
|
44
|
+
<div class="flex items-center gap-2.5 min-w-0">
|
|
45
|
+
{#if back}
|
|
46
|
+
{@render back()}
|
|
47
|
+
{:else if onBack}
|
|
48
|
+
<Button
|
|
49
|
+
variant="ghost"
|
|
50
|
+
size="icon-xs"
|
|
51
|
+
class="shrink-0 text-muted-foreground hover:text-foreground"
|
|
52
|
+
onclick={onBack}
|
|
53
|
+
aria-label={backLabel}
|
|
54
|
+
title={backLabel}
|
|
55
|
+
>
|
|
56
|
+
<ArrowLeft class="size-4" />
|
|
57
|
+
</Button>
|
|
31
58
|
{/if}
|
|
59
|
+
<div class="min-w-0">
|
|
60
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
61
|
+
<h1 class={cn('font-semibold text-foreground truncate', isCompact ? 'text-lg' : 'text-xl')}>
|
|
62
|
+
{title}
|
|
63
|
+
</h1>
|
|
64
|
+
{#if tags}
|
|
65
|
+
<div class="inline-flex items-center gap-1.5">
|
|
66
|
+
{@render tags()}
|
|
67
|
+
</div>
|
|
68
|
+
{/if}
|
|
69
|
+
</div>
|
|
70
|
+
{#if description}
|
|
71
|
+
<p class={cn('text-muted-foreground', isCompact ? 'text-xs' : 'text-sm')}>{description}</p>
|
|
72
|
+
{/if}
|
|
73
|
+
</div>
|
|
32
74
|
</div>
|
|
33
|
-
{#if actions}
|
|
34
|
-
<div class=
|
|
35
|
-
{
|
|
75
|
+
{#if actions || extra}
|
|
76
|
+
<div class={cn('flex items-center shrink-0', isCompact ? 'gap-1.5' : 'gap-2')}>
|
|
77
|
+
{#if extra}
|
|
78
|
+
{@render extra()}
|
|
79
|
+
{/if}
|
|
80
|
+
{#if actions}
|
|
81
|
+
{@render actions()}
|
|
82
|
+
{/if}
|
|
36
83
|
</div>
|
|
37
84
|
{/if}
|
|
38
85
|
</div>
|
|
@@ -3,7 +3,13 @@ interface Props {
|
|
|
3
3
|
title: string;
|
|
4
4
|
description?: string;
|
|
5
5
|
actions?: Snippet;
|
|
6
|
+
extra?: Snippet;
|
|
7
|
+
tags?: Snippet;
|
|
8
|
+
back?: Snippet;
|
|
9
|
+
onBack?: () => void;
|
|
10
|
+
backLabel?: string;
|
|
6
11
|
showBreadcrumbs?: boolean;
|
|
12
|
+
density?: 'compact' | 'comfortable';
|
|
7
13
|
class?: string;
|
|
8
14
|
}
|
|
9
15
|
declare const PageHeader: import("svelte").Component<Props, {}, "">;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { captureAdminContext, useShow, getResource } from '@svadmin/core';
|
|
3
3
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
|
+
import type { Snippet } from 'svelte';
|
|
4
5
|
import * as Card from './ui/card/index.js';
|
|
5
6
|
import { Skeleton } from './ui/skeleton/index.js';
|
|
6
7
|
import PageHeader from './PageHeader.svelte';
|
|
@@ -13,8 +14,26 @@
|
|
|
13
14
|
|
|
14
15
|
const i18n = useTranslation();
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
interface Props {
|
|
18
|
+
resourceName: string;
|
|
19
|
+
id: string | number;
|
|
20
|
+
density?: 'compact' | 'comfortable';
|
|
21
|
+
headerActions?: Snippet;
|
|
22
|
+
children?: Snippet;
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
resourceName,
|
|
28
|
+
id,
|
|
29
|
+
density = 'comfortable',
|
|
30
|
+
headerActions,
|
|
31
|
+
children,
|
|
32
|
+
class: className = '',
|
|
33
|
+
}: Props = $props();
|
|
34
|
+
|
|
17
35
|
const adminContext = captureAdminContext();
|
|
36
|
+
const isCompact = $derived(density === 'compact');
|
|
18
37
|
|
|
19
38
|
const resource = $derived(getResource(resourceName));
|
|
20
39
|
const showFields = $derived(resource.fields.filter(f => f.showInShow !== false));
|
|
@@ -22,8 +41,12 @@
|
|
|
22
41
|
const query = useShow({ get resource() { return resourceName; }, get id() { return id; } });
|
|
23
42
|
</script>
|
|
24
43
|
|
|
25
|
-
<div class="space-y-6 {className}">
|
|
26
|
-
<PageHeader
|
|
44
|
+
<div class="{isCompact ? 'space-y-4' : 'space-y-6'} {className}">
|
|
45
|
+
<PageHeader
|
|
46
|
+
title="{resource.label} {i18n.t('common.detail')} #{id}"
|
|
47
|
+
{density}
|
|
48
|
+
onBack={() => adminContext.navigate(`/${resourceName}`)}
|
|
49
|
+
>
|
|
27
50
|
{#snippet actions()}
|
|
28
51
|
<ListButton resource={resourceName} hideText />
|
|
29
52
|
{#if resource.canEdit !== false}
|
|
@@ -36,6 +59,9 @@
|
|
|
36
59
|
<DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => adminContext.navigate(`/${resourceName}`)} />
|
|
37
60
|
{/if}
|
|
38
61
|
<RefreshButton resource={resourceName} hideText />
|
|
62
|
+
{#if headerActions}
|
|
63
|
+
{@render headerActions()}
|
|
64
|
+
{/if}
|
|
39
65
|
{/snippet}
|
|
40
66
|
</PageHeader>
|
|
41
67
|
|
|
@@ -43,7 +69,7 @@
|
|
|
43
69
|
<Card.Root class="overflow-hidden border-border/40 shadow-sm">
|
|
44
70
|
<Card.Content class="p-0">
|
|
45
71
|
{#each showFields.slice(0, 6) as _, i (i)}
|
|
46
|
-
<div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4 {i % 2 === 1 ? 'bg-muted/20' : ''}">
|
|
72
|
+
<div class="flex flex-col sm:flex-row {isCompact ? 'px-4 py-2' : 'px-4 sm:px-6 py-3 sm:py-4'} {i % 2 === 1 ? 'bg-muted/20' : ''}">
|
|
47
73
|
<Skeleton class="h-4 w-1/2 sm:w-1/4" />
|
|
48
74
|
<Skeleton class="h-4 w-3/4 sm:w-2/5 mt-1 sm:mt-0 sm:ml-auto" />
|
|
49
75
|
</div>
|
|
@@ -52,13 +78,13 @@
|
|
|
52
78
|
</Card.Root>
|
|
53
79
|
{:else if query.data?.data}
|
|
54
80
|
<Card.Root class="overflow-hidden border-border/40 shadow-sm">
|
|
55
|
-
<Card.Content class="p-0">
|
|
81
|
+
<Card.Content class="p-0 divide-y divide-border/20">
|
|
56
82
|
{#each showFields as field, i (i)}
|
|
57
83
|
{@const value = ((query.data as { data: Record<string, unknown> }).data as Record<string, unknown>)[field.key]}
|
|
58
84
|
{@const DisplayComponent = getDisplayComponent(field.type)}
|
|
59
|
-
<div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4 {i % 2 === 1 ? 'bg-muted/
|
|
60
|
-
<div class="sm:w-1/3 text-xs sm:text-sm font-medium text-muted-foreground mb-1 sm:mb-0">{field.label}</div>
|
|
61
|
-
<div class="sm:w-2/3 text-sm text-foreground">
|
|
85
|
+
<div class="flex flex-col sm:flex-row {isCompact ? 'px-4 py-2.5 sm:px-5 sm:py-2.5' : 'px-4 sm:px-6 py-3 sm:py-4'} {i % 2 === 1 ? 'bg-muted/10' : ''}">
|
|
86
|
+
<div class="sm:w-1/3 {isCompact ? 'text-xs' : 'text-xs sm:text-sm'} font-medium text-muted-foreground mb-1 sm:mb-0">{field.label}</div>
|
|
87
|
+
<div class="sm:w-2/3 {isCompact ? 'text-xs sm:text-sm' : 'text-sm'} text-foreground">
|
|
62
88
|
{#if DisplayComponent && value != null}
|
|
63
89
|
<DisplayComponent
|
|
64
90
|
{value}
|
|
@@ -73,6 +99,9 @@
|
|
|
73
99
|
{/each}
|
|
74
100
|
</Card.Content>
|
|
75
101
|
</Card.Root>
|
|
102
|
+
{#if children}
|
|
103
|
+
{@render children()}
|
|
104
|
+
{/if}
|
|
76
105
|
{:else}
|
|
77
106
|
<Card.Root class="overflow-hidden border-border/40 shadow-sm">
|
|
78
107
|
<Card.Content class="p-8 text-center">
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
type
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
2
3
|
resourceName: string;
|
|
3
4
|
id: string | number;
|
|
5
|
+
density?: 'compact' | 'comfortable';
|
|
6
|
+
headerActions?: Snippet;
|
|
7
|
+
children?: Snippet;
|
|
4
8
|
class?: string;
|
|
5
|
-
}
|
|
6
|
-
declare const ShowPage: import("svelte").Component
|
|
9
|
+
}
|
|
10
|
+
declare const ShowPage: import("svelte").Component<Props, {}, "">;
|
|
7
11
|
type ShowPage = ReturnType<typeof ShowPage>;
|
|
8
12
|
export default ShowPage;
|
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { useNavigation } from '@svadmin/core';
|
|
2
|
+
import { useNavigation, getResource, useTranslation } from '@svadmin/core';
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { List } from '@lucide/svelte';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const i18n = useTranslation();
|
|
7
|
+
|
|
8
|
+
let { resource: resourceName, hideText = false, label, onBeforeNavigate, class: className = '' } = $props<{
|
|
7
9
|
resource: string;
|
|
8
10
|
hideText?: boolean;
|
|
11
|
+
label?: string;
|
|
12
|
+
onBeforeNavigate?: (navigate: () => void) => void;
|
|
9
13
|
class?: string;
|
|
10
14
|
}>();
|
|
11
15
|
|
|
12
16
|
const nav = useNavigation();
|
|
17
|
+
const res = $derived((() => {
|
|
18
|
+
try { return getResource(resourceName); } catch { return null; }
|
|
19
|
+
})());
|
|
20
|
+
const displayLabel = $derived(label ?? res?.label ?? i18n.t('common.list') ?? resourceName);
|
|
21
|
+
|
|
22
|
+
function navigateToList() {
|
|
23
|
+
const navigate = () => nav.list(resourceName);
|
|
24
|
+
if (onBeforeNavigate) onBeforeNavigate(navigate);
|
|
25
|
+
else navigate();
|
|
26
|
+
}
|
|
13
27
|
</script>
|
|
14
28
|
|
|
15
29
|
<Button
|
|
16
30
|
variant="outline"
|
|
17
31
|
size={hideText ? 'icon' : 'sm'}
|
|
18
32
|
class={className}
|
|
19
|
-
onclick={
|
|
33
|
+
onclick={navigateToList}
|
|
20
34
|
>
|
|
21
35
|
<List class="h-4 w-4" />
|
|
22
|
-
{#if !hideText}<span class="ml-1">{
|
|
36
|
+
{#if !hideText}<span class="ml-1">{displayLabel}</span>{/if}
|
|
23
37
|
</Button>
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
const i18n = useTranslation();
|
|
9
9
|
|
|
10
|
-
let { resource, recordItemId, hideText = false, accessControl = { enabled: true, hideIfUnauthorized: true }, class: className = '' } = $props<{
|
|
10
|
+
let { resource, recordItemId, hideText = false, accessControl = { enabled: true, hideIfUnauthorized: true }, onBeforeNavigate, class: className = '' } = $props<{
|
|
11
11
|
resource: string;
|
|
12
12
|
recordItemId: string | number;
|
|
13
13
|
hideText?: boolean;
|
|
14
14
|
accessControl?: ButtonAccessControl;
|
|
15
|
+
onBeforeNavigate?: (navigate: () => void) => void;
|
|
15
16
|
class?: string;
|
|
16
17
|
}>();
|
|
17
18
|
|
|
@@ -24,6 +25,12 @@
|
|
|
24
25
|
queryOptions: { enabled: accessControl?.enabled ?? true }
|
|
25
26
|
}));
|
|
26
27
|
const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
|
|
28
|
+
|
|
29
|
+
function navigateToRecord() {
|
|
30
|
+
const navigate = () => nav.show(resource, recordItemId);
|
|
31
|
+
if (onBeforeNavigate) onBeforeNavigate(navigate);
|
|
32
|
+
else navigate();
|
|
33
|
+
}
|
|
27
34
|
</script>
|
|
28
35
|
|
|
29
36
|
{#if !hidden}
|
|
@@ -33,7 +40,7 @@
|
|
|
33
40
|
size={hideText ? 'icon' : 'sm'}
|
|
34
41
|
class={className}
|
|
35
42
|
disabled={!can.allowed}
|
|
36
|
-
onclick={
|
|
43
|
+
onclick={navigateToRecord}
|
|
37
44
|
>
|
|
38
45
|
<Eye class="h-4 w-4" />
|
|
39
46
|
{#if !hideText}<span class="ml-1">{i18n.t('common.detail')}</span>{/if}
|
|
@@ -4,6 +4,7 @@ type $$ComponentProps = {
|
|
|
4
4
|
recordItemId: string | number;
|
|
5
5
|
hideText?: boolean;
|
|
6
6
|
accessControl?: ButtonAccessControl;
|
|
7
|
+
onBeforeNavigate?: (navigate: () => void) => void;
|
|
7
8
|
class?: string;
|
|
8
9
|
};
|
|
9
10
|
declare const ShowButton: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
@@ -1,32 +1,105 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
-
import { Search, X } from '@lucide/svelte';
|
|
3
|
+
import { Search, X, SlidersHorizontal, ChevronDown, ChevronUp } from '@lucide/svelte';
|
|
4
4
|
import { Button } from '../ui/button/index.js';
|
|
5
5
|
import { Input } from '../ui/input/index.js';
|
|
6
|
+
import { Badge } from '../ui/badge/index.js';
|
|
7
|
+
import { cn } from '../../utils.js';
|
|
8
|
+
|
|
6
9
|
interface Props {
|
|
7
10
|
query?: string;
|
|
8
11
|
placeholder?: string;
|
|
9
12
|
clearLabel?: string;
|
|
13
|
+
advancedLabel?: string;
|
|
14
|
+
showSearch?: boolean;
|
|
15
|
+
density?: 'compact' | 'comfortable';
|
|
16
|
+
activeFilterCount?: number;
|
|
17
|
+
advancedOpen?: boolean;
|
|
10
18
|
filters?: Snippet;
|
|
11
19
|
actions?: Snippet;
|
|
20
|
+
advanced?: Snippet;
|
|
12
21
|
class?: string;
|
|
13
22
|
}
|
|
23
|
+
|
|
14
24
|
let {
|
|
15
25
|
query = $bindable(''),
|
|
16
26
|
placeholder = 'Search',
|
|
17
27
|
clearLabel = 'Clear search',
|
|
28
|
+
advancedLabel = 'Filters',
|
|
29
|
+
showSearch = true,
|
|
30
|
+
density = 'compact',
|
|
31
|
+
activeFilterCount = 0,
|
|
32
|
+
advancedOpen = $bindable(false),
|
|
18
33
|
filters,
|
|
19
34
|
actions,
|
|
35
|
+
advanced,
|
|
20
36
|
class: className = '',
|
|
21
37
|
}: Props = $props();
|
|
38
|
+
|
|
39
|
+
const isCompact = $derived(density === 'compact');
|
|
22
40
|
</script>
|
|
23
41
|
|
|
24
|
-
<div class={'
|
|
25
|
-
<div class="
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
42
|
+
<div class={cn('space-y-2', className)}>
|
|
43
|
+
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
44
|
+
{#if showSearch}
|
|
45
|
+
<div class="relative min-w-0 flex-1">
|
|
46
|
+
<Search class={cn('pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground', isCompact ? 'size-3.5' : 'size-4')} />
|
|
47
|
+
<Input
|
|
48
|
+
bind:value={query}
|
|
49
|
+
{placeholder}
|
|
50
|
+
class={cn('pl-9 pr-9', isCompact ? 'h-8 text-xs' : 'h-9 text-sm')}
|
|
51
|
+
aria-label={placeholder}
|
|
52
|
+
/>
|
|
53
|
+
{#if query}
|
|
54
|
+
<Button
|
|
55
|
+
variant="ghost"
|
|
56
|
+
size="icon-xs"
|
|
57
|
+
class="absolute right-1 top-1/2 -translate-y-1/2"
|
|
58
|
+
aria-label={clearLabel}
|
|
59
|
+
title={clearLabel}
|
|
60
|
+
onclick={() => query = ''}
|
|
61
|
+
>
|
|
62
|
+
<X class="size-3.5" />
|
|
63
|
+
</Button>
|
|
64
|
+
{/if}
|
|
65
|
+
</div>
|
|
66
|
+
{/if}
|
|
67
|
+
{#if filters}
|
|
68
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
69
|
+
{@render filters()}
|
|
70
|
+
</div>
|
|
71
|
+
{/if}
|
|
72
|
+
{#if advanced}
|
|
73
|
+
<Button
|
|
74
|
+
variant={advancedOpen ? 'secondary' : 'outline'}
|
|
75
|
+
size={isCompact ? 'sm' : 'default'}
|
|
76
|
+
class={cn('gap-1.5 shrink-0', isCompact ? 'h-8 text-xs px-2.5' : '')}
|
|
77
|
+
onclick={() => advancedOpen = !advancedOpen}
|
|
78
|
+
aria-expanded={advancedOpen}
|
|
79
|
+
>
|
|
80
|
+
<SlidersHorizontal class="size-3.5" />
|
|
81
|
+
<span>{advancedLabel}</span>
|
|
82
|
+
{#if activeFilterCount > 0}
|
|
83
|
+
<Badge variant="secondary" class="ml-0.5 h-4 min-w-4 px-1 text-[10px] leading-none font-semibold">
|
|
84
|
+
{activeFilterCount}
|
|
85
|
+
</Badge>
|
|
86
|
+
{/if}
|
|
87
|
+
{#if advancedOpen}
|
|
88
|
+
<ChevronUp class="size-3 text-muted-foreground ml-0.5" />
|
|
89
|
+
{:else}
|
|
90
|
+
<ChevronDown class="size-3 text-muted-foreground ml-0.5" />
|
|
91
|
+
{/if}
|
|
92
|
+
</Button>
|
|
93
|
+
{/if}
|
|
94
|
+
{#if actions}
|
|
95
|
+
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
96
|
+
{@render actions()}
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
29
99
|
</div>
|
|
30
|
-
{#if
|
|
31
|
-
|
|
100
|
+
{#if advanced && advancedOpen}
|
|
101
|
+
<div class="rounded-lg border border-border/60 bg-muted/20 p-3">
|
|
102
|
+
{@render advanced()}
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
32
105
|
</div>
|
|
@@ -3,10 +3,16 @@ interface Props {
|
|
|
3
3
|
query?: string;
|
|
4
4
|
placeholder?: string;
|
|
5
5
|
clearLabel?: string;
|
|
6
|
+
advancedLabel?: string;
|
|
7
|
+
showSearch?: boolean;
|
|
8
|
+
density?: 'compact' | 'comfortable';
|
|
9
|
+
activeFilterCount?: number;
|
|
10
|
+
advancedOpen?: boolean;
|
|
6
11
|
filters?: Snippet;
|
|
7
12
|
actions?: Snippet;
|
|
13
|
+
advanced?: Snippet;
|
|
8
14
|
class?: string;
|
|
9
15
|
}
|
|
10
|
-
declare const FilterToolbar: import("svelte").Component<Props, {}, "query">;
|
|
16
|
+
declare const FilterToolbar: import("svelte").Component<Props, {}, "query" | "advancedOpen">;
|
|
11
17
|
type FilterToolbar = ReturnType<typeof FilterToolbar>;
|
|
12
18
|
export default FilterToolbar;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../../utils.js';
|
|
3
|
+
|
|
4
|
+
export interface StatusTabItem {
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
count?: number | string;
|
|
8
|
+
tone?: 'default' | 'success' | 'warning' | 'destructive' | 'info' | 'muted';
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
items: StatusTabItem[];
|
|
14
|
+
value?: string;
|
|
15
|
+
density?: 'compact' | 'comfortable';
|
|
16
|
+
variant?: 'pills' | 'underline' | 'segmented';
|
|
17
|
+
onchange?: (key: string) => void;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
items = [],
|
|
23
|
+
value = $bindable(items[0]?.key ?? ''),
|
|
24
|
+
density = 'compact',
|
|
25
|
+
variant = 'pills',
|
|
26
|
+
onchange,
|
|
27
|
+
class: className = '',
|
|
28
|
+
}: Props = $props();
|
|
29
|
+
|
|
30
|
+
function selectTab(key: string, disabled?: boolean) {
|
|
31
|
+
if (disabled || key === value) return;
|
|
32
|
+
value = key;
|
|
33
|
+
onchange?.(key);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type TabKeyboardEvent = KeyboardEvent & { currentTarget: EventTarget & HTMLButtonElement };
|
|
37
|
+
|
|
38
|
+
function handleKeydown(event: TabKeyboardEvent) {
|
|
39
|
+
const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
|
|
40
|
+
if (!keys.includes(event.key)) return;
|
|
41
|
+
|
|
42
|
+
const tabList = event.currentTarget.closest('[role="tablist"]');
|
|
43
|
+
if (!tabList) return;
|
|
44
|
+
|
|
45
|
+
const tabs = Array.from(
|
|
46
|
+
tabList.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)')
|
|
47
|
+
);
|
|
48
|
+
const currentIndex = tabs.indexOf(event.currentTarget);
|
|
49
|
+
if (currentIndex === -1 || tabs.length === 0) return;
|
|
50
|
+
|
|
51
|
+
const nextIndex = event.key === 'Home'
|
|
52
|
+
? 0
|
|
53
|
+
: event.key === 'End'
|
|
54
|
+
? tabs.length - 1
|
|
55
|
+
: event.key === 'ArrowRight' || event.key === 'ArrowDown'
|
|
56
|
+
? (currentIndex + 1) % tabs.length
|
|
57
|
+
: (currentIndex - 1 + tabs.length) % tabs.length;
|
|
58
|
+
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
tabs[nextIndex]?.focus();
|
|
61
|
+
tabs[nextIndex]?.click();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isCompact = $derived(density === 'compact');
|
|
65
|
+
|
|
66
|
+
const toneClasses: Record<string, { activeBadge: string; inactiveBadge: string }> = {
|
|
67
|
+
default: {
|
|
68
|
+
activeBadge: 'bg-primary/20 text-primary',
|
|
69
|
+
inactiveBadge: 'bg-muted text-muted-foreground',
|
|
70
|
+
},
|
|
71
|
+
success: {
|
|
72
|
+
activeBadge: 'bg-success/25 text-success',
|
|
73
|
+
inactiveBadge: 'bg-success/10 text-success',
|
|
74
|
+
},
|
|
75
|
+
warning: {
|
|
76
|
+
activeBadge: 'bg-warning/25 text-warning-foreground',
|
|
77
|
+
inactiveBadge: 'bg-warning/10 text-warning-foreground',
|
|
78
|
+
},
|
|
79
|
+
destructive: {
|
|
80
|
+
activeBadge: 'bg-destructive/25 text-destructive',
|
|
81
|
+
inactiveBadge: 'bg-destructive/10 text-destructive',
|
|
82
|
+
},
|
|
83
|
+
info: {
|
|
84
|
+
activeBadge: 'bg-info/25 text-info',
|
|
85
|
+
inactiveBadge: 'bg-info/10 text-info',
|
|
86
|
+
},
|
|
87
|
+
muted: {
|
|
88
|
+
activeBadge: 'bg-muted text-foreground',
|
|
89
|
+
inactiveBadge: 'bg-muted/50 text-muted-foreground',
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<div
|
|
95
|
+
role="tablist"
|
|
96
|
+
aria-orientation="horizontal"
|
|
97
|
+
data-svadmin-status-tabs
|
|
98
|
+
data-density={density}
|
|
99
|
+
data-variant={variant}
|
|
100
|
+
class={cn(
|
|
101
|
+
'flex items-center gap-1 overflow-x-auto',
|
|
102
|
+
variant === 'segmented' && 'rounded-lg bg-muted p-1',
|
|
103
|
+
variant === 'underline' && 'border-b border-border gap-2',
|
|
104
|
+
className
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{#each items as item (item.key)}
|
|
108
|
+
{@const active = value === item.key}
|
|
109
|
+
{@const tone = item.tone ?? 'default'}
|
|
110
|
+
{@const badgeTone = toneClasses[tone] ?? toneClasses.default}
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
role="tab"
|
|
114
|
+
aria-selected={active}
|
|
115
|
+
tabindex={active ? 0 : -1}
|
|
116
|
+
disabled={item.disabled}
|
|
117
|
+
onclick={() => selectTab(item.key, item.disabled)}
|
|
118
|
+
onkeydown={handleKeydown}
|
|
119
|
+
class={cn(
|
|
120
|
+
'inline-flex items-center gap-1.5 whitespace-nowrap font-medium transition-colors cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',
|
|
121
|
+
isCompact ? 'text-xs px-2.5 py-1' : 'text-sm px-3 py-1.5',
|
|
122
|
+
variant === 'pills' && [
|
|
123
|
+
'rounded-md border',
|
|
124
|
+
active
|
|
125
|
+
? 'border-border bg-background text-foreground shadow-xs font-semibold'
|
|
126
|
+
: 'border-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground',
|
|
127
|
+
],
|
|
128
|
+
variant === 'segmented' && [
|
|
129
|
+
'rounded-md',
|
|
130
|
+
active
|
|
131
|
+
? 'bg-background text-foreground shadow-xs font-semibold'
|
|
132
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
133
|
+
],
|
|
134
|
+
variant === 'underline' && [
|
|
135
|
+
'-mb-px border-b-2 rounded-none px-3 pb-2 pt-1',
|
|
136
|
+
active
|
|
137
|
+
? 'border-primary text-primary font-semibold'
|
|
138
|
+
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
|
|
139
|
+
]
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
<span>{item.label}</span>
|
|
143
|
+
{#if item.count !== undefined}
|
|
144
|
+
<span
|
|
145
|
+
class={cn(
|
|
146
|
+
'inline-flex items-center justify-center rounded-full font-semibold tabular-nums leading-none',
|
|
147
|
+
isCompact ? 'min-w-4 px-1 py-0.5 text-[10px]' : 'min-w-5 px-1.5 py-0.5 text-xs',
|
|
148
|
+
active ? badgeTone.activeBadge : badgeTone.inactiveBadge
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{item.count}
|
|
152
|
+
</span>
|
|
153
|
+
{/if}
|
|
154
|
+
</button>
|
|
155
|
+
{/each}
|
|
156
|
+
</div>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface StatusTabItem {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
count?: number | string;
|
|
5
|
+
tone?: 'default' | 'success' | 'warning' | 'destructive' | 'info' | 'muted';
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
interface Props {
|
|
9
|
+
items: StatusTabItem[];
|
|
10
|
+
value?: string;
|
|
11
|
+
density?: 'compact' | 'comfortable';
|
|
12
|
+
variant?: 'pills' | 'underline' | 'segmented';
|
|
13
|
+
onchange?: (key: string) => void;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
declare const StatusTabs: import("svelte").Component<Props, {}, "value">;
|
|
17
|
+
type StatusTabs = ReturnType<typeof StatusTabs>;
|
|
18
|
+
export default StatusTabs;
|
|
@@ -12,6 +12,8 @@ export { default as SettingsFieldRow } from './SettingsFieldRow.svelte';
|
|
|
12
12
|
export { default as MetricBlock } from './MetricBlock.svelte';
|
|
13
13
|
export { default as DescriptionList } from './DescriptionList.svelte';
|
|
14
14
|
export { default as StatusBadge } from './StatusBadge.svelte';
|
|
15
|
+
export { default as StatusTabs } from './StatusTabs.svelte';
|
|
16
|
+
export type { StatusTabItem } from './StatusTabs.svelte';
|
|
15
17
|
export { default as FilterToolbar } from './FilterToolbar.svelte';
|
|
16
18
|
export { default as DataState } from './DataState.svelte';
|
|
17
19
|
export type { DataStateKind } from './DataState.svelte';
|
|
@@ -12,6 +12,7 @@ export { default as SettingsFieldRow } from './SettingsFieldRow.svelte';
|
|
|
12
12
|
export { default as MetricBlock } from './MetricBlock.svelte';
|
|
13
13
|
export { default as DescriptionList } from './DescriptionList.svelte';
|
|
14
14
|
export { default as StatusBadge } from './StatusBadge.svelte';
|
|
15
|
+
export { default as StatusTabs } from './StatusTabs.svelte';
|
|
15
16
|
export { default as FilterToolbar } from './FilterToolbar.svelte';
|
|
16
17
|
export { default as DataState } from './DataState.svelte';
|
|
17
18
|
export { default as ProjectCard } from './ProjectCard.svelte';
|
package/dist/index.d.ts
CHANGED
|
@@ -172,6 +172,8 @@ export { default as DescriptionList } from './components/content/DescriptionList
|
|
|
172
172
|
export type { DescriptionItem } from './components/content/DescriptionList.svelte';
|
|
173
173
|
export { default as StatusBadge } from './components/content/StatusBadge.svelte';
|
|
174
174
|
export type { Status } from './components/content/StatusBadge.svelte';
|
|
175
|
+
export { default as StatusTabs } from './components/content/StatusTabs.svelte';
|
|
176
|
+
export type { StatusTabItem } from './components/content/StatusTabs.svelte';
|
|
175
177
|
export { default as FilterToolbar } from './components/content/FilterToolbar.svelte';
|
|
176
178
|
export { default as DataState } from './components/content/DataState.svelte';
|
|
177
179
|
export type { DataStateKind } from './components/content/DataState.svelte';
|
package/dist/index.js
CHANGED
|
@@ -183,6 +183,7 @@ export { default as SettingsFieldRow } from './components/content/SettingsFieldR
|
|
|
183
183
|
export { default as MetricBlock } from './components/content/MetricBlock.svelte';
|
|
184
184
|
export { default as DescriptionList } from './components/content/DescriptionList.svelte';
|
|
185
185
|
export { default as StatusBadge } from './components/content/StatusBadge.svelte';
|
|
186
|
+
export { default as StatusTabs } from './components/content/StatusTabs.svelte';
|
|
186
187
|
export { default as FilterToolbar } from './components/content/FilterToolbar.svelte';
|
|
187
188
|
export { default as DataState } from './components/content/DataState.svelte';
|
|
188
189
|
export { default as ProjectCard } from './components/content/ProjectCard.svelte';
|