@svadmin/ui 0.31.0 → 0.32.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 (35) hide show
  1. package/dist/app.css +7 -6
  2. package/dist/components/AdminApp.svelte +19 -24
  3. package/dist/components/AdminApp.svelte.d.ts +3 -1
  4. package/dist/components/AnimatedCounter.svelte +8 -2
  5. package/dist/components/AnomalyBadge.svelte +5 -1
  6. package/dist/components/AuditLogViewer.svelte +9 -2
  7. package/dist/components/AutoForm.svelte +29 -8
  8. package/dist/components/AutoForm.svelte.d.ts +1 -1
  9. package/dist/components/AutoTable.svelte +35 -15
  10. package/dist/components/Breadcrumbs.svelte +3 -3
  11. package/dist/components/ChatDialog.svelte +39 -1
  12. package/dist/components/CommandPalette.svelte +3 -3
  13. package/dist/components/ConfirmDialog.svelte +16 -2
  14. package/dist/components/DraggableHeader.svelte +1 -2
  15. package/dist/components/FieldRenderer.svelte +37 -10
  16. package/dist/components/FieldRenderer.svelte.d.ts +1 -1
  17. package/dist/components/Header.svelte +5 -6
  18. package/dist/components/Layout.svelte +10 -5
  19. package/dist/components/LiveIndicator.svelte +7 -1
  20. package/dist/components/LoginPage.svelte +1 -1
  21. package/dist/components/NavigateToResource.svelte +4 -2
  22. package/dist/components/PermissionMatrix.svelte +6 -1
  23. package/dist/components/ShowPage.svelte +9 -0
  24. package/dist/components/Sidebar.svelte +16 -9
  25. package/dist/components/SidebarItem.svelte +2 -2
  26. package/dist/components/SmartSuggest.svelte +12 -2
  27. package/dist/components/StepsForm.svelte +22 -26
  28. package/dist/components/TenantSwitcher.svelte +2 -2
  29. package/dist/components/VirtualTable.svelte +8 -6
  30. package/dist/components/VoiceInput.svelte +12 -0
  31. package/dist/components/ui/dialog/dialog-content.svelte +1 -1
  32. package/dist/components/ui/dialog/dialog-overlay.svelte +1 -1
  33. package/dist/components/ui/dropdown-menu/dropdown-menu-content.svelte +2 -1
  34. package/dist/router-state.svelte.js +5 -3
  35. package/package.json +2 -2
@@ -20,11 +20,11 @@
20
20
  import type { Snippet } from 'svelte';
21
21
  import { getRichTextEditor } from '../editor-config.svelte.js';
22
22
 
23
- let { field, value, onchange, children } = $props<{
23
+ let { field, value, onchange, disabled, children } = $props<{
24
24
  field: FieldDefinition;
25
25
  value: unknown;
26
26
  onchange: (val: unknown) => void;
27
- /** Optional custom renderer snippet */
27
+ disabled?: boolean;
28
28
  children?: Snippet;
29
29
  }>();
30
30
 
@@ -98,6 +98,7 @@
98
98
  value={strVal}
99
99
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
100
100
  required={field.required}
101
+ {disabled}
101
102
  placeholder={t('field.enterValue', { label: field.label })}
102
103
  />
103
104
 
@@ -108,6 +109,7 @@
108
109
  value={strVal}
109
110
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
110
111
  required={field.required}
112
+ {disabled}
111
113
  placeholder="name@example.com"
112
114
  />
113
115
 
@@ -118,6 +120,7 @@
118
120
  value={strVal}
119
121
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
120
122
  required={field.required}
123
+ {disabled}
121
124
  placeholder="https://"
122
125
  />
123
126
 
@@ -128,6 +131,7 @@
128
131
  value={strVal}
129
132
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
130
133
  required={field.required}
134
+ {disabled}
131
135
  placeholder="+1 (555) 000-0000"
132
136
  />
133
137
 
@@ -139,6 +143,7 @@
139
143
  value={strVal || '#000000'}
140
144
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
141
145
  class="h-10 w-14 cursor-pointer rounded-md border border-input bg-background p-1"
146
+ disabled={disabled}
142
147
  />
143
148
  <Input
144
149
  type="text"
@@ -146,6 +151,7 @@
146
151
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
147
152
  placeholder="#000000"
148
153
  class="max-w-32 font-mono text-sm"
154
+ {disabled}
149
155
  />
150
156
  {#if strVal}
151
157
  <span
@@ -160,8 +166,12 @@
160
166
  id={field.key}
161
167
  type="number"
162
168
  value={String(numVal)}
163
- oninput={(e) => onchange(parseFloat((e.target as HTMLInputElement).value) || 0)}
169
+ oninput={(e) => {
170
+ const v = (e.target as HTMLInputElement).value;
171
+ onchange(v === '' ? null : Number(v));
172
+ }}
164
173
  required={field.required}
174
+ {disabled}
165
175
  />
166
176
 
167
177
  {:else if field.type === 'richtext'}
@@ -173,6 +183,7 @@
173
183
  placeholder={t('field.enterValue', { label: field.label })}
174
184
  preset="full"
175
185
  onchange={(html: string) => onchange(html)}
186
+ {disabled}
176
187
  />
177
188
  {:else}
178
189
  <Textarea
@@ -180,6 +191,7 @@
180
191
  value={strVal}
181
192
  oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
182
193
  required={field.required}
194
+ {disabled}
183
195
  rows={10}
184
196
  placeholder={t('field.enterValue', { label: field.label })}
185
197
  class="resize-y"
@@ -192,6 +204,7 @@
192
204
  value={strVal}
193
205
  oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
194
206
  required={field.required}
207
+ {disabled}
195
208
  rows={4}
196
209
  placeholder={t('field.enterValue', { label: field.label })}
197
210
  class="resize-y"
@@ -206,24 +219,31 @@
206
219
  optionLabel={field.optionLabel ?? 'title'}
207
220
  optionValue={field.optionValue ?? 'id'}
208
221
  placeholder={t('field.selectPlaceholder')}
222
+ {disabled}
209
223
  />
210
224
 
211
225
  {:else if field.type === 'select'}
212
226
  {#if (field.options?.length ?? 0) > 8}
213
- <!-- Many options → use ComboboxField-style search -->
214
- <ComboboxField
227
+ <select
215
228
  id={field.key}
216
- resource=""
217
- value={value as string | number | null}
218
- onchange={(v) => onchange(v)}
219
- searchable={false}
220
- />
229
+ 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"
230
+ value={strVal}
231
+ onchange={(e) => onchange((e.target as HTMLSelectElement).value)}
232
+ required={field.required}
233
+ disabled={disabled}
234
+ >
235
+ <option value="">{t('field.selectPlaceholder')}</option>
236
+ {#each field.options ?? [] as opt}
237
+ <option value={opt.value}>{opt.label}</option>
238
+ {/each}
239
+ </select>
221
240
  {:else}
222
241
  <Select
223
242
  id={field.key}
224
243
  value={strVal}
225
244
  onchange={(e) => onchange((e.target as HTMLSelectElement).value)}
226
245
  required={field.required}
246
+ {disabled}
227
247
  placeholder={t('field.selectPlaceholder')}
228
248
  >
229
249
  {#each field.options ?? [] as opt}
@@ -244,6 +264,7 @@
244
264
  id={`${field.key}-${opt.value}`}
245
265
  checked={multiVal.includes(opt.value)}
246
266
  onCheckedChange={() => toggleMulti(opt.value)}
267
+ disabled={disabled}
247
268
  />
248
269
  {opt.label}
249
270
  </label>
@@ -270,6 +291,7 @@
270
291
  id={field.key}
271
292
  checked={boolVal}
272
293
  onCheckedChange={(v) => onchange(v)}
294
+ disabled={disabled}
273
295
  />
274
296
  <span class="text-sm text-muted-foreground">{boolVal ? t('common.yes') : t('common.no')}</span>
275
297
  </div>
@@ -294,6 +316,7 @@
294
316
  type="text"
295
317
  placeholder={t('field.tagsPlaceholder')}
296
318
  onkeydown={handleTagKeydown}
319
+ {disabled}
297
320
  />
298
321
  </div>
299
322
 
@@ -304,6 +327,7 @@
304
327
  value={strVal}
305
328
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
306
329
  required={field.required}
330
+ {disabled}
307
331
  />
308
332
 
309
333
  {:else if field.type === 'images'}
@@ -317,6 +341,7 @@
317
341
  oninput={(e) => updateImage(i, (e.target as HTMLInputElement).value)}
318
342
  placeholder="https://example.com/image.jpg"
319
343
  class="flex-1"
344
+ {disabled}
320
345
  />
321
346
  {#if url}
322
347
  <img src={url} alt="preview" class="h-9 w-9 rounded object-cover border" />
@@ -342,6 +367,7 @@
342
367
  // keep raw text until valid JSON
343
368
  }
344
369
  }}
370
+ {disabled}
345
371
  rows={6}
346
372
  class="resize-y font-mono text-xs"
347
373
  />
@@ -352,6 +378,7 @@
352
378
  type="text"
353
379
  value={strVal}
354
380
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
381
+ {disabled}
355
382
  />
356
383
  {/if}
357
384
  </div>
@@ -5,7 +5,7 @@ type $$ComponentProps = {
5
5
  field: FieldDefinition;
6
6
  value: unknown;
7
7
  onchange: (val: unknown) => void;
8
- /** Optional custom renderer snippet */
8
+ disabled?: boolean;
9
9
  children?: Snippet;
10
10
  };
11
11
  declare const FieldRenderer: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -26,12 +26,11 @@
26
26
  siteUrl?: string;
27
27
  }>();
28
28
 
29
- // Retrieve optional component overrides from registry
30
- const registry = getComponentRegistry() || {} as any;
31
- const CustomBreadcrumbs = registry.Breadcrumbs;
32
- const CustomThemeToggle = registry.ThemeToggle;
33
- const CustomUserMenu = registry.UserMenu;
34
- const CustomNotificationPanel = registry.NotificationPanel;
29
+ const registry = $derived(getComponentRegistry() || {} as any);
30
+ const CustomBreadcrumbs = $derived(registry.Breadcrumbs);
31
+ const CustomThemeToggle = $derived(registry.ThemeToggle);
32
+ const CustomUserMenu = $derived(registry.UserMenu);
33
+ const CustomNotificationPanel = $derived(registry.NotificationPanel);
35
34
  </script>
36
35
 
37
36
  <header class="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center justify-between bg-card/60 backdrop-blur-md px-4 md:px-6">
@@ -2,7 +2,6 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import { fade, fly } from 'svelte/transition';
4
4
  import Sidebar from './Sidebar.svelte';
5
- import Toast from './Toast.svelte';
6
5
  import Header from './Header.svelte';
7
6
  import CommandPalette from './CommandPalette.svelte';
8
7
  import KeyboardShortcuts from './KeyboardShortcuts.svelte';
@@ -12,7 +11,7 @@
12
11
  import { getAuthProvider } from '@svadmin/core';
13
12
  import type { Identity, MenuItem } from '@svadmin/core';
14
13
  import { navigate } from '@svadmin/core/router';
15
- import { currentPath } from '@svadmin/core/router';
14
+ import { getPath } from '../router-state.svelte.js';
16
15
  import { Skeleton } from './ui/skeleton/index.js';
17
16
  import { Button } from './ui/button/index.js';
18
17
  import * as Sheet from './ui/sheet/index.js';
@@ -150,7 +149,7 @@
150
149
 
151
150
  <!-- Content area -->
152
151
  <main class="flex-1 overflow-y-auto p-6 md:p-8">
153
- {#key currentPath()}
152
+ {#key getPath()}
154
153
  <div in:fly={{ x: 20, duration: 150 }} out:fade={{ duration: 80 }}>
155
154
  {@render children()}
156
155
  </div>
@@ -158,8 +157,14 @@
158
157
  </main>
159
158
  </div>
160
159
  </div>
161
- <CommandPalette bind:open={commandOpen} />
160
+ <CommandPalette
161
+ bind:open={commandOpen}
162
+ onAskAI={(q) => {
163
+ if (typeof window !== 'undefined') {
164
+ window.dispatchEvent(new CustomEvent('svadmin:ask-ai', { detail: q }));
165
+ }
166
+ }}
167
+ />
162
168
  <KeyboardShortcuts bind:open={shortcutsOpen} />
163
169
  <ChatDialog />
164
- <Toast />
165
170
  {/if}
@@ -23,9 +23,15 @@
23
23
  type StatusKey = 'connected' | 'connecting' | 'disconnected';
24
24
  const cfg = $derived(statusConfigs[status as StatusKey]);
25
25
 
26
+ let now = $state(Date.now());
27
+ $effect(() => {
28
+ const itv = setInterval(() => { now = Date.now(); }, 1000);
29
+ return () => clearInterval(itv);
30
+ });
31
+
26
32
  const timeSinceEvent = $derived(
27
33
  lastEvent?.timestamp
28
- ? Math.floor((Date.now() - lastEvent.timestamp) / 1000)
34
+ ? Math.floor((now - lastEvent.timestamp) / 1000)
29
35
  : null
30
36
  );
31
37
  </script>
@@ -38,7 +38,7 @@
38
38
  socialProviders?: SocialProvider[];
39
39
  }>();
40
40
 
41
- const login = useLogin();
41
+ const login = useLogin({ errorMessage: false });
42
42
  const authProvider = getAuthProvider();
43
43
 
44
44
  let identifier = $state('');
@@ -4,9 +4,11 @@
4
4
 
5
5
  const { menuItems } = useMenu();
6
6
 
7
+ let navigated = false;
7
8
  $effect(() => {
8
- // Automatically navigate to the first defined resource
9
- if (menuItems.length > 0) {
9
+ // Navigate to first resource ONCE — avoid infinite re-triggers
10
+ if (!navigated && menuItems.length > 0) {
11
+ navigated = true;
10
12
  navigate(menuItems[0].route);
11
13
  }
12
14
  });
@@ -15,7 +15,7 @@
15
15
  isGranted,
16
16
 
17
17
  // Interactive states
18
- selectedRole = $bindable(roles[0]?.code || ''),
18
+ selectedRole = $bindable(''),
19
19
  loading = false,
20
20
 
21
21
  // Feedback and alerts (passed down from upper business logic)
@@ -44,6 +44,11 @@
44
44
  onToggle: (role: string, resource: string, action: string, grant: boolean) => void;
45
45
  } = $props();
46
46
 
47
+ $effect(() => {
48
+ if (!selectedRole && roles.length > 0) {
49
+ selectedRole = roles[0].code;
50
+ }
51
+ });
47
52
  </script>
48
53
 
49
54
  <div class="h-full flex flex-col pt-2">
@@ -73,5 +73,14 @@
73
73
  {/each}
74
74
  </Card.Content>
75
75
  </Card.Root>
76
+ {:else}
77
+ <Card.Root class="overflow-hidden border-border/40 shadow-sm">
78
+ <Card.Content class="p-8 text-center">
79
+ <p class="text-muted-foreground">{t('common.noData')}</p>
80
+ {#if query.isError}
81
+ <p class="text-sm text-destructive mt-2">{(query.error as Error)?.message ?? t('common.operationFailed')}</p>
82
+ {/if}
83
+ </Card.Content>
84
+ </Card.Root>
76
85
  {/if}
77
86
  </div>
@@ -80,10 +80,11 @@
80
80
  let navItems = $state.raw<NavItem[]>([]);
81
81
 
82
82
  $effect(() => {
83
- // Re-run when locale or resources change
84
83
  const localeVal = getLocale();
84
+ const currentResources = getResources();
85
+ let cancelled = false;
85
86
 
86
- Promise.all(resources.map(async (r) => {
87
+ Promise.all(currentResources.map(async (r) => {
87
88
  try {
88
89
  const { can } = await canAccessAsync(r.name, 'list');
89
90
  return { r, can };
@@ -91,6 +92,7 @@
91
92
  return { r, can: false };
92
93
  }
93
94
  })).then(results => {
95
+ if (cancelled) return;
94
96
  const items: NavItem[] = [{ path: '/', label: t('common.home'), Icon: LayoutDashboard }];
95
97
  for (const { r, can } of results) {
96
98
  if (can) {
@@ -104,6 +106,8 @@
104
106
  }
105
107
  navItems = items;
106
108
  });
109
+
110
+ return () => { cancelled = true; };
107
111
  });
108
112
 
109
113
  /** Toggle between available locales */
@@ -254,9 +258,10 @@
254
258
  {@const active = isActive(item.path)}
255
259
  <a
256
260
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
257
- class="flex items-center gap-3 rounded-lg px-3 {pyClassGroupItem} text-sm font-medium transition-all duration-200
261
+ onclick={(e) => { e.preventDefault(); navigate(item.path); }}
262
+ class="flex items-center gap-3 rounded-xl px-3 {pyClassGroupItem} text-sm font-medium transition-all duration-300
258
263
  {active
259
- ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
264
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20 translate-x-1'
260
265
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
261
266
  >
262
267
  <item.Icon class="h-4 w-4 flex-shrink-0" />
@@ -277,10 +282,11 @@
277
282
  <a
278
283
  {...props}
279
284
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
280
- class="flex items-center justify-center gap-3 rounded-lg px-3 {pyClass} text-sm font-medium transition-all duration-200
285
+ onclick={(e) => { e.preventDefault(); navigate(item.path); }}
286
+ class="flex items-center justify-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-300
281
287
  {active
282
- ? 'bg-sidebar-accent text-sidebar-accent-foreground'
283
- : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
288
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20'
289
+ : 'text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground'}"
284
290
  >
285
291
  <item.Icon class="h-4 w-4 flex-shrink-0" />
286
292
  </a>
@@ -293,9 +299,10 @@
293
299
  {:else}
294
300
  <a
295
301
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
296
- class="flex items-center gap-3 rounded-lg px-3 {pyClass} text-sm font-medium transition-all duration-200
302
+ onclick={(e) => { e.preventDefault(); navigate(item.path); }}
303
+ class="flex items-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-300
297
304
  {active
298
- ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
305
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20 translate-x-1'
299
306
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
300
307
  >
301
308
  <item.Icon class="h-4 w-4 flex-shrink-0" />
@@ -102,7 +102,7 @@
102
102
  rel={isExternal ? 'noopener noreferrer' : undefined}
103
103
  class="flex items-center justify-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
104
104
  {active
105
- ? 'bg-sidebar-accent text-sidebar-accent-foreground'
105
+ ? 'bg-card text-sidebar-primary shadow-sm ring-1 ring-foreground/5'
106
106
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
107
107
  >
108
108
  <Icon class="h-4 w-4 flex-shrink-0" />
@@ -121,7 +121,7 @@
121
121
  rel={isExternal ? 'noopener noreferrer' : undefined}
122
122
  class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
123
123
  {active
124
- ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
124
+ ? 'bg-card text-sidebar-primary shadow-sm ring-1 ring-foreground/5 font-semibold before:absolute before:left-0 before:top-2 before:bottom-2 before:w-[3px] before:bg-primary before:rounded-r-md overflow-hidden relative'
125
125
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
126
126
  style="padding-left: {12 + depth * 12}px"
127
127
  >
@@ -49,10 +49,20 @@
49
49
  return '';
50
50
  });
51
51
 
52
+ import { getChatProvider } from '@svadmin/core';
53
+
52
54
  const suffixText = $derived(ghostText ? ghostText.slice(value.length) : '');
53
55
 
56
+ const resolvedProvider = $derived(provider ?? (() => {
57
+ try {
58
+ return getChatProvider();
59
+ } catch {
60
+ return null;
61
+ }
62
+ })());
63
+
54
64
  async function predict() {
55
- if (!provider || !value.trim()) {
65
+ if (!resolvedProvider || !value.trim()) {
56
66
  suggestion = '';
57
67
  return;
58
68
  }
@@ -70,7 +80,7 @@ Provide ONLY the most likely completion of the exact phrase the user is typing.
70
80
  Include the user's input in your returned string.
71
81
  Do not include any other text, Markdown formatting, quotes, or explanations.`;
72
82
 
73
- const result = provider.sendMessage(
83
+ const result = resolvedProvider.sendMessage(
74
84
  [{ id: 'prompt', role: 'user', content: prompt, timestamp: Date.now() }],
75
85
  { signal: abortController.signal }
76
86
  );
@@ -67,12 +67,12 @@
67
67
  });
68
68
 
69
69
  const totalSteps = $derived(steps.length);
70
- const progressValue = $derived(((form.currentStep + 1) / totalSteps) * 100);
71
- const isLastStep = $derived(form.currentStep >= totalSteps - 1);
70
+ const progressValue = $derived(((form.steps.currentStep + 1) / totalSteps) * 100);
71
+ const isLastStep = $derived(form.steps.currentStep >= totalSteps - 1);
72
72
 
73
73
  // Fields for the current step
74
74
  const currentFields = $derived(
75
- steps[form.currentStep]?.fields
75
+ steps[form.steps.currentStep]?.fields
76
76
  .map(key => resource.fields.find(f => f.key === key))
77
77
  .filter((f): f is FieldDefinition => f != null && f.key !== primaryKey) ?? []
78
78
  );
@@ -81,15 +81,11 @@
81
81
  function validateCurrentStep(): boolean {
82
82
  let valid = true;
83
83
  form.clearErrors();
84
- for (const field of currentFields) {
85
- const value = form.values[field.key];
86
- if (field.required && (value === undefined || value === null || value === '')) {
87
- form.setFieldError(field.key, t('validation.required'));
88
- valid = false;
89
- } else if (field.validate) {
90
- const msg = field.validate(value);
91
- if (msg) {
92
- form.setFieldError(field.key, msg);
84
+ const errors = validator(form.values);
85
+ if (errors) {
86
+ for (const field of currentFields) {
87
+ if (errors[field.key]) {
88
+ form.setFieldError(field.key, errors[field.key]);
93
89
  valid = false;
94
90
  }
95
91
  }
@@ -101,7 +97,7 @@
101
97
  if (!validateCurrentStep()) return;
102
98
 
103
99
  if (!isLastStep) {
104
- form.next();
100
+ form.steps.nextStep();
105
101
  return;
106
102
  }
107
103
 
@@ -118,17 +114,17 @@
118
114
  <Button
119
115
  variant="ghost"
120
116
  type="button"
121
- class="flex items-center gap-2 transition-colors h-auto py-1 px-2 {i === form.currentStep ? 'text-primary font-semibold' : 'text-muted-foreground'}"
122
- onclick={() => form.gotoStep(i)}
117
+ class="flex items-center gap-2 transition-colors h-auto py-1 px-2 {i === form.steps.currentStep ? 'text-primary font-semibold' : 'text-muted-foreground'}"
118
+ onclick={() => form.steps.gotoStep(i)}
123
119
  >
124
120
  <span
125
121
  class="flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium transition-colors"
126
- class:bg-primary={i <= form.currentStep}
127
- class:text-primary-foreground={i <= form.currentStep}
128
- class:bg-muted={i > form.currentStep}
129
- class:text-muted-foreground={i > form.currentStep}
122
+ class:bg-primary={i <= form.steps.currentStep}
123
+ class:text-primary-foreground={i <= form.steps.currentStep}
124
+ class:bg-muted={i > form.steps.currentStep}
125
+ class:text-muted-foreground={i > form.steps.currentStep}
130
126
  >
131
- {#if i < form.currentStep}
127
+ {#if i < form.steps.currentStep}
132
128
  <Check class="h-3.5 w-3.5" />
133
129
  {:else}
134
130
  {i + 1}
@@ -137,7 +133,7 @@
137
133
  <span class="hidden sm:inline">{step.title}</span>
138
134
  </Button>
139
135
  {#if i < steps.length - 1}
140
- <div class="flex-1 mx-2 h-px bg-border" class:bg-primary={i < form.currentStep}></div>
136
+ <div class="flex-1 mx-2 h-px bg-border" class:bg-primary={i < form.steps.currentStep}></div>
141
137
  {/if}
142
138
  {/each}
143
139
  </div>
@@ -147,13 +143,13 @@
147
143
  <!-- Step content -->
148
144
  <Card.Root>
149
145
  <Card.CardHeader>
150
- <Card.CardTitle>{steps[form.currentStep]?.title ?? ''}</Card.CardTitle>
146
+ <Card.CardTitle>{steps[form.steps.currentStep]?.title ?? ''}</Card.CardTitle>
151
147
  <Badge variant="outline" class="w-fit">
152
- {t('common.step')} {form.currentStep + 1} / {totalSteps}
148
+ {t('common.step')} {form.steps.currentStep + 1} / {totalSteps}
153
149
  </Badge>
154
150
  </Card.CardHeader>
155
151
  <Card.CardContent>
156
- {#key form.currentStep}
152
+ {#key form.steps.currentStep}
157
153
  <form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} in:fly={{ x: 30, duration: 250 }}>
158
154
  {#each currentFields as field (field.key)}
159
155
  <FieldRenderer
@@ -170,10 +166,10 @@
170
166
  <Button
171
167
  type="button"
172
168
  variant="outline"
173
- onclick={() => form.currentStep === 0 ? navigate(`/${resourceName}`) : form.prev()}
169
+ onclick={() => form.steps.currentStep === 0 ? navigate(`/${resourceName}`) : form.steps.prevStep()}
174
170
  >
175
171
  <ArrowLeft class="h-4 w-4" data-icon="inline-start" />
176
- {form.currentStep === 0 ? t('common.cancel') : t('common.back')}
172
+ {form.steps.currentStep === 0 ? t('common.cancel') : t('common.back')}
177
173
  </Button>
178
174
 
179
175
  <Button type="submit" disabled={form.submitting}>
@@ -7,7 +7,7 @@
7
7
 
8
8
  let {
9
9
  tenants = [],
10
- currentTenantId = $bindable(''),
10
+ currentTenantId = $bindable(undefined as string | undefined),
11
11
  collapsed = false,
12
12
  onSwitch,
13
13
  headerSnippet,
@@ -74,7 +74,7 @@
74
74
  {/if}
75
75
  </div>
76
76
  <span class="flex-1 truncate">{tenant.name}</span>
77
- {#if tenant.id === currentTenantId}
77
+ {#if tenant.id === currentTenant?.id}
78
78
  <Check class="h-4 w-4 text-primary" />
79
79
  {/if}
80
80
  </DropdownMenu.Item>
@@ -66,11 +66,13 @@
66
66
  style="height: {containerHeight}px;"
67
67
  onscroll={handleScroll}
68
68
  >
69
- <div style="position: relative; height: {totalHeight}px; width: 100%;">
70
- {#each visibleItems as { item, index, offsetY } (index)}
71
- <div style="position: absolute; top: {offsetY}px; left: 0; right: 0; height: {rowHeight}px;">
69
+ <table class="w-full relative" style="border-spacing: 0;">
70
+ <tbody style="display: block;">
71
+ <tr style="height: {visibleRange.start * rowHeight}px; border: none; padding: 0; display: block;"></tr>
72
+ {#each visibleItems as { item, index } (index)}
72
73
  {@render row(item, index)}
73
- </div>
74
- {/each}
75
- </div>
74
+ {/each}
75
+ <tr style="height: {Math.max(0, (items.length - visibleRange.end - 1) * rowHeight)}px; border: none; padding: 0; display: block;"></tr>
76
+ </tbody>
77
+ </table>
76
78
  </div>
@@ -89,6 +89,18 @@
89
89
  isListening = false;
90
90
  if (onend) onend();
91
91
  };
92
+
93
+ return () => {
94
+ if (recognition) {
95
+ isListening = false;
96
+ recognition.onend = () => {};
97
+ recognition.stop();
98
+ recognition.onstart = null;
99
+ recognition.onresult = null;
100
+ recognition.onerror = null;
101
+ recognition.onend = null;
102
+ }
103
+ };
92
104
  });
93
105
 
94
106
  function toggleListening(e?: MouseEvent) {
@@ -28,7 +28,7 @@
28
28
  bind:ref
29
29
  data-slot="dialog-content"
30
30
  class={cn(
31
- "bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-lg p-6 text-sm ring-1 duration-100 sm:max-w-lg fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
31
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-6 text-sm ring-1 duration-300 sm:max-w-lg fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none shadow-2xl",
32
32
  className
33
33
  )}
34
34
  {...restProps}
@@ -12,6 +12,6 @@
12
12
  <DialogPrimitive.Overlay
13
13
  bind:ref
14
14
  data-slot="dialog-overlay"
15
- class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
15
+ class={cn("data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-black/20 duration-200 backdrop-blur-sm fixed inset-0 isolate z-50", className)}
16
16
  {...restProps}
17
17
  />
@@ -15,7 +15,8 @@
15
15
  ...restProps
16
16
  }: Props = $props();
17
17
 
18
- let isOpen = $derived(getContext<() => boolean>("svadmin-dropdown-open")?.() ?? true);
18
+ const getOpen = getContext<() => boolean>("svadmin-dropdown-open");
19
+ let isOpen = $derived(getOpen?.() ?? true);
19
20
  </script>
20
21
 
21
22
  {#if isOpen}