@svadmin/ui 0.49.3 → 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.
Files changed (31) hide show
  1. package/dist/components/AutoForm.svelte +105 -54
  2. package/dist/components/AutoForm.svelte.d.ts +4 -0
  3. package/dist/components/Breadcrumbs.svelte +3 -2
  4. package/dist/components/Breadcrumbs.svelte.d.ts +1 -0
  5. package/dist/components/CreatePage.svelte +25 -5
  6. package/dist/components/CreatePage.svelte.d.ts +3 -0
  7. package/dist/components/DrawerForm.svelte +1 -1
  8. package/dist/components/EditPage.svelte +25 -5
  9. package/dist/components/EditPage.svelte.d.ts +3 -0
  10. package/dist/components/FieldRenderer.svelte +15 -2
  11. package/dist/components/FieldRenderer.svelte.d.ts +1 -0
  12. package/dist/components/ListPage.svelte +15 -3
  13. package/dist/components/ListPage.svelte.d.ts +3 -0
  14. package/dist/components/ModalForm.svelte +1 -1
  15. package/dist/components/PageHeader.svelte +57 -10
  16. package/dist/components/PageHeader.svelte.d.ts +6 -0
  17. package/dist/components/ShowPage.svelte +37 -8
  18. package/dist/components/ShowPage.svelte.d.ts +7 -3
  19. package/dist/components/buttons/ListButton.svelte +18 -4
  20. package/dist/components/buttons/ListButton.svelte.d.ts +2 -0
  21. package/dist/components/buttons/ShowButton.svelte +9 -2
  22. package/dist/components/buttons/ShowButton.svelte.d.ts +1 -0
  23. package/dist/components/content/FilterToolbar.svelte +81 -8
  24. package/dist/components/content/FilterToolbar.svelte.d.ts +7 -1
  25. package/dist/components/content/StatusTabs.svelte +156 -0
  26. package/dist/components/content/StatusTabs.svelte.d.ts +18 -0
  27. package/dist/components/content/index.d.ts +2 -0
  28. package/dist/components/content/index.js +1 -0
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +1 -0
  31. 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 { 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>;
@@ -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="mb-4">
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,6 +1,7 @@
1
1
  import { type MenuItem } from '@svadmin/core';
2
2
  type $$ComponentProps = {
3
3
  menu?: MenuItem[];
4
+ class?: string;
4
5
  };
5
6
  declare const Breadcrumbs: import("svelte").Component<$$ComponentProps, {}, "">;
6
7
  type Breadcrumbs = ReturnType<typeof Breadcrumbs>;
@@ -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 title={pageTitle}>
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 {resourceName} mode="create" />
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 title={pageTitle}>
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 {resourceName} mode="edit" {id} />
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 class="space-y-1.5" data-svadmin-field data-svadmin-field-key={field.key}>
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)}
@@ -8,6 +8,7 @@ type $$ComponentProps = {
8
8
  disabled?: boolean;
9
9
  invalid?: boolean;
10
10
  errorId?: string;
11
+ density?: 'compact' | 'comfortable';
11
12
  children?: Snippet;
12
13
  };
13
14
  declare const FieldRenderer: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -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="space-y-4 {className}">
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="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
27
- <div>
28
- <h1 class="text-xl font-semibold tracking-tight text-foreground">{title}</h1>
29
- {#if description}
30
- <p class="text-sm text-muted-foreground">{description}</p>
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="flex items-center gap-2">
35
- {@render actions()}
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
- let { resourceName, id, class: className = '' } = $props<{ resourceName: string; id: string | number; class?: string }>();
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 title="{resource.label} {i18n.t('common.detail')}">
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/20' : ''}">
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 $$ComponentProps = {
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<$$ComponentProps, {}, "">;
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
- let { resource, hideText = false, class: className = '' } = $props<{
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={() => nav.list(resource)}
33
+ onclick={navigateToList}
20
34
  >
21
35
  <List class="h-4 w-4" />
22
- {#if !hideText}<span class="ml-1">{resource}</span>{/if}
36
+ {#if !hideText}<span class="ml-1">{displayLabel}</span>{/if}
23
37
  </Button>
@@ -1,6 +1,8 @@
1
1
  type $$ComponentProps = {
2
2
  resource: string;
3
3
  hideText?: boolean;
4
+ label?: string;
5
+ onBeforeNavigate?: (navigate: () => void) => void;
4
6
  class?: string;
5
7
  };
6
8
  declare const ListButton: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -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={() => nav.show(resource, recordItemId)}
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={'flex flex-col gap-2 sm:flex-row sm:items-center ' + className}>
25
- <div class="relative min-w-0 flex-1">
26
- <Search class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
27
- <Input bind:value={query} {placeholder} class="pl-9 pr-9" aria-label={placeholder} />
28
- {#if query}<Button variant="ghost" size="icon-xs" class="absolute right-1 top-1/2 -translate-y-1/2" aria-label={clearLabel} title={clearLabel} onclick={() => query = ''}><X class="size-3.5" /></Button>{/if}
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 filters}<div class="flex flex-wrap items-center gap-2">{@render filters()}</div>{/if}
31
- {#if actions}<div class="flex shrink-0 flex-wrap items-center gap-2">{@render actions()}</div>{/if}
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.49.3",
3
+ "version": "0.50.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [