@svadmin/ui 0.49.0 → 0.49.2

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.
@@ -351,6 +351,44 @@
351
351
  const renderedParams = $derived(params);
352
352
  const renderedHasRouteResource = $derived(!renderedParams.resource || resourceNames.has(renderedParams.resource));
353
353
  const renderedResourcePages = $derived(renderedParams.resource ? resourcePages?.[renderedParams.resource] : undefined);
354
+ const documentTitle = $derived.by(() => {
355
+ const resource = renderedParams.resource
356
+ ? resources.find((candidate) => candidate.name === renderedParams.resource)
357
+ : undefined;
358
+ let pageLabel = '';
359
+
360
+ if (resource) {
361
+ pageLabel = resource.label || resource.name;
362
+ } else if (renderedRoute.startsWith('/settings') || renderedRoute === '/account/:tab') {
363
+ const tab = renderedParams.tab === 'api-keys'
364
+ ? 'api'
365
+ : renderedParams.tab === 'audit-logs'
366
+ ? 'audit'
367
+ : renderedParams.tab ?? 'profile';
368
+ const labels: Record<string, string> = {
369
+ profile: translation.t('settings.profile'),
370
+ appearance: translation.t('settings.appearance'),
371
+ notifications: translation.t('settings.notifications'),
372
+ security: translation.t('settings.security'),
373
+ roles: translation.t('settings.rolesAndPermissions'),
374
+ integrations: translation.t('settings.integrations'),
375
+ api: translation.t('settings.api'),
376
+ audit: translation.t('settings.auditLogs'),
377
+ about: translation.t('settings.about'),
378
+ };
379
+ pageLabel = labels[tab] ?? translation.t('settings.title');
380
+ } else if (renderedRoute === '/login') {
381
+ pageLabel = translation.t('auth.login');
382
+ } else if (renderedRoute === '/' || renderedRoute === '/inventory-dashboard') {
383
+ pageLabel = translation.t('common.dashboard');
384
+ }
385
+
386
+ return pageLabel && pageLabel !== title ? `${pageLabel} - ${title}` : title;
387
+ });
388
+
389
+ $effect(() => {
390
+ if (typeof document !== 'undefined') document.title = documentTitle;
391
+ });
354
392
 
355
393
  async function navigateWithinApp(path: string, options?: { replaceState?: boolean }) {
356
394
  await adminContext.navigate(path, options);
@@ -578,12 +616,12 @@
578
616
  {@const Comp = renderedResourcePages?.create ?? mergedComponents.AutoForm}
579
617
  <Comp resourceName={renderedParams.resource} mode="create" />
580
618
  {/key}
581
- {:else if (renderedRoute === '/:resource/edit/:id' || renderedRoute === '/:parent/:parentId/:resource/edit/:id') && renderedHasRouteResource}
619
+ {:else if (renderedRoute === '/:resource/edit/:id' || renderedRoute === '/:resource/:id/edit' || renderedRoute === '/:parent/:parentId/:resource/edit/:id') && renderedHasRouteResource}
582
620
  {#key `${renderedParams.resource}-${renderedParams.id}`}
583
621
  {@const Comp = renderedResourcePages?.edit ?? mergedComponents.AutoForm}
584
622
  <Comp resourceName={renderedParams.resource} mode="edit" id={renderedParams.id} />
585
623
  {/key}
586
- {:else if (renderedRoute === '/:resource/show/:id' || renderedRoute === '/:parent/:parentId/:resource/show/:id') && renderedHasRouteResource}
624
+ {:else if (renderedRoute === '/:resource/show/:id' || renderedRoute === '/:resource/:id' || renderedRoute === '/:parent/:parentId/:resource/show/:id') && renderedHasRouteResource}
587
625
  {#key `${renderedParams.resource}-${renderedParams.id}`}
588
626
  {@const Comp = renderedResourcePages?.show ?? mergedComponents.ShowPage}
589
627
  <Comp resourceName={renderedParams.resource} id={renderedParams.id} />
@@ -111,8 +111,6 @@
111
111
  sorters: externalSorters,
112
112
  }: Props = $props();
113
113
  const adminContext = captureAdminContext();
114
- const headerDensityClass = $derived(density === 'compact' ? 'h-8 py-1.5 text-[0.6875rem]' : 'h-9 py-2.5 text-xs');
115
- const cellDensityClass = $derived(density === 'compact' ? 'h-8 px-2 py-1 text-xs' : 'h-10 p-2 text-sm');
116
114
 
117
115
  const resource = $derived(getResource(resourceName));
118
116
  const primaryKey = $derived(resource.primaryKey ?? 'id');
@@ -710,7 +708,7 @@
710
708
  <div in:fade={{ duration: 150 }}>
711
709
  <!-- Desktop Table (hidden on mobile) -->
712
710
  <div class="hidden md:block">
713
- <Table.Root>
711
+ <Table.Root {density}>
714
712
  <Table.Header>
715
713
  {#each tableView.headerGroups as headerGroup, _i (_i)}
716
714
  {@const visibleHeaders = headerGroup.headers.filter((header: Header<TableFeatures, BaseRecord, unknown>) => isColumnVisible(header.column.id))}
@@ -723,7 +721,7 @@
723
721
  {@const header = col.header as typeof headerGroup.headers[0]}
724
722
  <Table.Head
725
723
  {...dragProps}
726
- class={cn('border-b border-border bg-muted/25 font-medium tracking-normal text-muted-foreground hover:bg-muted/40', headerDensityClass, dragProps.class)}
724
+ class={cn('border-b border-border bg-muted/25 font-medium tracking-normal text-muted-foreground hover:bg-muted/40', dragProps.class)}
727
725
  style={header_getSize(header) != null && header_getSize(header) !== 150 ? `width:${header_getSize(header)}px` : undefined}
728
726
  >
729
727
  {#if header.id === '_select'}
@@ -768,7 +766,7 @@
768
766
  {#snippet child({ props })}
769
767
  <Table.Row {...props} class="transition-all duration-300 border-b border-border/10 {row_getIsSelected(row) ? 'bg-primary/5' : 'hover:bg-muted/20'}">
770
768
  {#each visibleCells as cell, _i (_i)}
771
- <Table.Cell class={cellDensityClass}>
769
+ <Table.Cell>
772
770
  {#if cell.column.id === '_select'}
773
771
  <Checkbox
774
772
  checked={row_getIsSelected(row)}
@@ -7,7 +7,10 @@
7
7
  type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
8
8
  open?: boolean;
9
9
  title?: string;
10
+ titleId?: string;
10
11
  description?: string;
12
+ descriptionId?: string;
13
+ closeLabel?: string;
11
14
  side?: 'left' | 'right';
12
15
  width?: string;
13
16
  onClose?: () => void;
@@ -24,6 +27,11 @@
24
27
  ref = $bindable(null),
25
28
  class: className,
26
29
  'aria-label': ariaLabel,
30
+ 'aria-labelledby': ariaLabelledby,
31
+ 'aria-describedby': ariaDescribedby,
32
+ titleId,
33
+ descriptionId,
34
+ closeLabel = 'Close',
27
35
  onClose,
28
36
  children,
29
37
  footer,
@@ -41,20 +49,23 @@
41
49
  bind:open
42
50
  {side}
43
51
  onClose={handleClose}
52
+ {closeLabel}
44
53
  class={cn('overflow-y-auto', width, className)}
45
54
  role="dialog"
46
55
  aria-modal="true"
47
- aria-label={ariaLabel || title || 'Details'}
56
+ aria-label={(ariaLabelledby || titleId) ? ariaLabel : (ariaLabel || title || 'Details')}
57
+ aria-labelledby={ariaLabelledby || (titleId ? titleId : undefined)}
58
+ aria-describedby={ariaDescribedby || (descriptionId ? descriptionId : undefined)}
48
59
  {...restProps}
49
60
  >
50
61
  <Sheet.Content class="flex h-full flex-col">
51
62
  {#if title || description}
52
63
  <Sheet.Header>
53
64
  {#if title}
54
- <Sheet.Title>{title}</Sheet.Title>
65
+ <Sheet.Title id={titleId}>{title}</Sheet.Title>
55
66
  {/if}
56
67
  {#if description}
57
- <Sheet.Description>{description}</Sheet.Description>
68
+ <Sheet.Description id={descriptionId}>{description}</Sheet.Description>
58
69
  {/if}
59
70
  </Sheet.Header>
60
71
  {/if}
@@ -4,7 +4,10 @@ import { type WithElementRef } from '../utils.js';
4
4
  type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
5
5
  open?: boolean;
6
6
  title?: string;
7
+ titleId?: string;
7
8
  description?: string;
9
+ descriptionId?: string;
10
+ closeLabel?: string;
8
11
  side?: 'left' | 'right';
9
12
  width?: string;
10
13
  onClose?: () => void;
@@ -188,6 +188,7 @@
188
188
  {siteUrl}
189
189
  {menu}
190
190
  showSearch={true}
191
+ showThemeToggle={true}
191
192
  onSearchClick={() => { commandOpen = true; }}
192
193
  >
193
194
  {#snippet children()}
@@ -20,7 +20,7 @@
20
20
 
21
21
  const identity = useGetIdentity();
22
22
  const notification = useNotification();
23
- const updatePw = useUpdatePassword();
23
+ const updatePw = useUpdatePassword({ successNotification: i18n.t('profile.passwordChanged') });
24
24
 
25
25
  const authProvider = $derived(adminContext.authProvider);
26
26
  const tenantIdentity = $derived(adminContext.tenantCacheKey?.__svadminTenant);
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { Ellipsis } from '@lucide/svelte';
3
- import type { Component, Snippet } from 'svelte';
3
+ import { tick, type Component, type Snippet } from 'svelte';
4
4
  import type { HTMLAttributes } from 'svelte/elements';
5
5
  import { cn, type WithElementRef } from '../utils.js';
6
6
  import * as DropdownMenu from './ui/dropdown-menu/index.js';
@@ -25,6 +25,9 @@
25
25
  children?: Snippet;
26
26
  moreContent?: Snippet;
27
27
  };
28
+ type RowActionsKeyboardEvent = KeyboardEvent & {
29
+ currentTarget: EventTarget & HTMLDivElement;
30
+ };
28
31
 
29
32
  let {
30
33
  actions = [],
@@ -32,6 +35,7 @@
32
35
  moreLabel = 'More actions',
33
36
  ref = $bindable(null),
34
37
  class: className,
38
+ onkeydown,
35
39
  children,
36
40
  moreContent,
37
41
  ...restProps
@@ -46,6 +50,9 @@
46
50
  );
47
51
  const visibleActions = $derived(availableActions.slice(0, visibleLimit));
48
52
  const overflowActions = $derived(availableActions.slice(visibleLimit));
53
+ let menuWasOpen = false;
54
+ let initialMenuFocus: 'first' | 'last' = 'first';
55
+ let restoreFocusOnClose = false;
49
56
 
50
57
  function resolvedVariant(action: RowActionItem): RowActionItem['variant'] {
51
58
  return action.variant ?? (action.danger ? 'destructive' : 'ghost');
@@ -56,15 +63,77 @@
56
63
  event.preventDefault();
57
64
  return;
58
65
  }
66
+ restoreFocusOnClose = true;
59
67
  overflowOpen = false;
60
68
  action.onclick?.(event);
61
69
  }
70
+
71
+ function menuElements(): HTMLElement[] {
72
+ return [...(ref?.querySelector('[data-slot="dropdown-menu-content"]')?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])]
73
+ .filter((element) => !element.matches(':disabled, [aria-disabled="true"]'));
74
+ }
75
+
76
+ function moveMenuFocus(event: KeyboardEvent, direction: 1 | -1): void {
77
+ const elements = menuElements();
78
+ if (!elements.length) return;
79
+ const currentIndex = elements.indexOf(document.activeElement as HTMLElement);
80
+ const nextIndex = currentIndex < 0
81
+ ? (direction > 0 ? 0 : elements.length - 1)
82
+ : (currentIndex + direction + elements.length) % elements.length;
83
+ event.preventDefault();
84
+ elements[nextIndex]?.focus();
85
+ }
86
+
87
+ function focusMenuBoundary(event: KeyboardEvent, boundary: 'first' | 'last'): void {
88
+ const elements = menuElements();
89
+ if (!elements.length) return;
90
+ event.preventDefault();
91
+ elements[boundary === 'first' ? 0 : elements.length - 1]?.focus();
92
+ }
93
+
94
+ function manageMenuKeyboard(event: RowActionsKeyboardEvent): void {
95
+ onkeydown?.(event);
96
+ if (event.defaultPrevented) return;
97
+ const target = event.target instanceof HTMLElement ? event.target : null;
98
+ const trigger = target?.closest<HTMLElement>('[data-slot="dropdown-menu-trigger"]');
99
+ const content = target?.closest<HTMLElement>('[data-slot="dropdown-menu-content"]');
100
+ if (trigger && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
101
+ event.preventDefault();
102
+ initialMenuFocus = event.key === 'ArrowUp' ? 'last' : 'first';
103
+ trigger.click();
104
+ } else if (content && event.key === 'Escape') {
105
+ event.preventDefault();
106
+ restoreFocusOnClose = true;
107
+ overflowOpen = false;
108
+ } else if (content && event.key === 'ArrowDown') moveMenuFocus(event, 1);
109
+ else if (content && event.key === 'ArrowUp') moveMenuFocus(event, -1);
110
+ else if (content && event.key === 'Home') focusMenuBoundary(event, 'first');
111
+ else if (content && event.key === 'End') focusMenuBoundary(event, 'last');
112
+ }
113
+
114
+ $effect(() => {
115
+ if (overflowOpen) {
116
+ void tick().then(() => {
117
+ menuWasOpen = true;
118
+ const elements = menuElements();
119
+ elements[initialMenuFocus === 'last' ? elements.length - 1 : 0]?.focus();
120
+ initialMenuFocus = 'first';
121
+ });
122
+ } else if (menuWasOpen) {
123
+ menuWasOpen = false;
124
+ if (restoreFocusOnClose) {
125
+ restoreFocusOnClose = false;
126
+ ref?.querySelector<HTMLElement>('[data-slot="dropdown-menu-trigger"]')?.focus();
127
+ }
128
+ }
129
+ });
62
130
  </script>
63
131
 
64
132
  <div
65
133
  bind:this={ref}
66
134
  data-slot="row-actions"
67
135
  class={cn("inline-flex items-center gap-1.5", className)}
136
+ onkeydown={manageMenuKeyboard}
68
137
  {...restProps}
69
138
  >
70
139
  {#if children}
@@ -109,12 +178,12 @@
109
178
  <DropdownMenu.Root bind:open={overflowOpen}>
110
179
  <DropdownMenu.Trigger>
111
180
  {#snippet child({ props })}
112
- <Button {...props} variant="ghost" size="icon-sm" title={moreLabel} aria-label={moreLabel}>
181
+ <Button {...props} variant="ghost" size="icon-sm" title={moreLabel} aria-label={moreLabel} aria-haspopup="menu" aria-expanded={overflowOpen}>
113
182
  <Ellipsis class="size-4" />
114
183
  </Button>
115
184
  {/snippet}
116
185
  </DropdownMenu.Trigger>
117
- <DropdownMenu.Content align="end" class="min-w-[8rem]">
186
+ <DropdownMenu.Content align="end" class="min-w-[8rem]" role="menu" aria-label={moreLabel}>
118
187
  {#each overflowActions as action (action)}
119
188
  {#if action.href}
120
189
  <Button
@@ -123,6 +192,8 @@
123
192
  size="sm"
124
193
  disabled={action.disabled}
125
194
  onclick={(event) => handleOverflowAction(action, event)}
195
+ role="menuitem"
196
+ tabindex={-1}
126
197
  class="h-auto w-full justify-start rounded-md px-2 py-1.5 text-sm font-normal"
127
198
  >
128
199
  {#if action.icon}
@@ -136,6 +207,8 @@
136
207
  onclick={(event) => handleOverflowAction(action, event)}
137
208
  disabled={action.disabled}
138
209
  destructive={action.danger || action.variant === 'destructive'}
210
+ role="menuitem"
211
+ tabindex={-1}
139
212
  >
140
213
  {#if action.icon}
141
214
  {@const Icon = action.icon}
@@ -1,4 +1,4 @@
1
- import type { Component, Snippet } from 'svelte';
1
+ import { type Component, type Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  import { type WithElementRef } from '../utils.js';
4
4
  export type RowActionItem = {
@@ -110,7 +110,7 @@
110
110
  let activeKey = $derived.by(() => {
111
111
  const tab = getParams().tab;
112
112
  if (!tab) return "profile";
113
- const alias = tab === "api-keys" ? "api" : tab;
113
+ const alias = tab === "api-keys" ? "api" : tab === "audit-logs" ? "audit" : tab;
114
114
  if (sectionKeys.has(alias)) return alias;
115
115
  return content ? alias : "profile";
116
116
  });
@@ -202,15 +202,26 @@
202
202
  return () => { cancelled = true; };
203
203
  });
204
204
 
205
- function toggleLocale() {
206
- const locales = i18n.getAvailableLocales();
207
- const current = i18n.locale;
208
- const idx = locales.indexOf(current);
209
- const next = locales[(idx + 1) % locales.length];
210
- i18n.setLocale(next);
205
+ const LOCALE_STORAGE_KEY = 'svadmin-locale';
206
+
207
+ function localeName(locale: string): string {
208
+ if (locale === 'zh-CN') return '简体中文';
209
+ if (locale === 'en') return 'English';
210
+ return locale;
211
211
  }
212
212
 
213
- const localeLabel = $derived(i18n.locale === 'zh-CN' ? '中' : 'EN');
213
+ function selectLocale(locale: string): void {
214
+ i18n.setLocale(locale);
215
+ if (typeof window !== 'undefined') localStorage.setItem(LOCALE_STORAGE_KEY, locale);
216
+ }
217
+
218
+ $effect(() => {
219
+ if (typeof window === 'undefined') return;
220
+ const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
221
+ if (storedLocale && i18n.getAvailableLocales().includes(storedLocale)) {
222
+ i18n.setLocale(storedLocale);
223
+ }
224
+ });
214
225
 
215
226
  const path = $derived(getPath());
216
227
 
@@ -274,6 +285,19 @@
274
285
  });
275
286
  </script>
276
287
 
288
+ {#snippet languageMenu()}
289
+ <select
290
+ aria-label={i18n.t('common.switchLanguage')}
291
+ value={i18n.locale}
292
+ onchange={(event) => selectLocale((event.target as HTMLSelectElement).value)}
293
+ class="h-8 w-14 rounded-md border border-border bg-sidebar px-1 text-[11px] font-semibold text-sidebar-foreground/70 outline-none focus:ring-2 focus:ring-ring"
294
+ >
295
+ {#each i18n.getAvailableLocales() as locale (locale)}
296
+ <option value={locale}>{localeName(locale)}</option>
297
+ {/each}
298
+ </select>
299
+ {/snippet}
300
+
277
301
 
278
302
 
279
303
  <aside
@@ -444,9 +468,7 @@
444
468
  </div>
445
469
  <div class="mt-1 flex items-center justify-between px-1">
446
470
  <div class="flex items-center gap-0.5">
447
- <TooltipButton tooltip={i18n.t('common.switchLanguage')} variant="ghost" size="icon-sm" onclick={toggleLocale} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
448
- <span class="text-[11px] font-semibold">{localeLabel}</span>
449
- </TooltipButton>
471
+ {@render languageMenu()}
450
472
  <TooltipButton tooltip={i18n.t('common.toggleTheme')} variant="ghost" size="icon-sm" onclick={toggleTheme} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
451
473
  {#if getResolvedTheme() === 'dark'}
452
474
  <Sun class="h-3.5 w-3.5" />
@@ -465,16 +487,7 @@
465
487
  </div>
466
488
  {:else if collapsed}
467
489
  <div class="flex flex-col items-center gap-0.5 px-2 py-3">
468
- <Tooltip.Root>
469
- <Tooltip.Trigger>
470
- {#snippet child({ props }: { props: Record<string, unknown> })}
471
- <Button {...props} variant="ghost" size="icon" onclick={toggleLocale} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
472
- <span class="text-[11px] font-semibold">{localeLabel}</span>
473
- </Button>
474
- {/snippet}
475
- </Tooltip.Trigger>
476
- <Tooltip.Content side="right">{i18n.t('common.switchLanguage')}</Tooltip.Content>
477
- </Tooltip.Root>
490
+ {@render languageMenu()}
478
491
  <Tooltip.Root>
479
492
  <Tooltip.Trigger>
480
493
  {#snippet child({ props }: { props: Record<string, unknown> })}
@@ -512,9 +525,7 @@
512
525
  </div>
513
526
  {:else}
514
527
  <div class="flex justify-center gap-1 p-3">
515
- <TooltipButton tooltip={i18n.t('common.switchLanguage')} variant="ghost" size="icon" onclick={toggleLocale} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
516
- <span class="text-[11px] font-semibold">{localeLabel}</span>
517
- </TooltipButton>
528
+ {@render languageMenu()}
518
529
  <TooltipButton tooltip={i18n.t('common.toggleTheme')} variant="ghost" size="icon" onclick={toggleTheme} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
519
530
  {#if getResolvedTheme() === 'dark'}
520
531
  <Sun class="h-3.5 w-3.5" />
@@ -18,6 +18,8 @@
18
18
  activeId: string;
19
19
  onselect?: (stage: WorkspaceStage) => void;
20
20
  ariaLabel?: string;
21
+ statusLabels?: Partial<Record<WorkspaceStageStatus, string>>;
22
+ getStageAriaLabel?: (stage: WorkspaceStage) => string;
21
23
  class?: string;
22
24
  }
23
25
 
@@ -26,6 +28,8 @@
26
28
  activeId,
27
29
  onselect,
28
30
  ariaLabel = 'Workflow stages',
31
+ statusLabels = {},
32
+ getStageAriaLabel,
29
33
  class: className = '',
30
34
  }: Props = $props();
31
35
 
@@ -35,6 +39,14 @@
35
39
  blocked: 'border-warning/40 bg-warning/15 text-warning-foreground',
36
40
  pending: 'border-border bg-background text-muted-foreground',
37
41
  };
42
+
43
+ const resolvedStatusLabels = $derived<Record<WorkspaceStageStatus, string>>({
44
+ complete: 'Complete',
45
+ current: 'Current stage',
46
+ blocked: 'Blocked',
47
+ pending: 'Pending',
48
+ ...statusLabels,
49
+ });
38
50
  </script>
39
51
 
40
52
  <nav aria-label={ariaLabel} class={cn('overflow-x-auto', className)} data-svadmin-workspace-stages>
@@ -45,7 +57,7 @@
45
57
  type="button"
46
58
  class="group grid min-w-28 grid-cols-[auto_minmax(0,1fr)] gap-x-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring sm:min-w-36"
47
59
  aria-current={stage.id === activeId ? 'step' : undefined}
48
- aria-label={`${stage.label}: ${stage.status}`}
60
+ aria-label={getStageAriaLabel?.(stage) || `${stage.label}: ${resolvedStatusLabels[stage.status]}`}
49
61
  onclick={() => onselect?.(stage)}
50
62
  >
51
63
  <span class={cn('flex size-7 items-center justify-center rounded-full border', markerClass[stage.status])}>
@@ -10,6 +10,8 @@ interface Props {
10
10
  activeId: string;
11
11
  onselect?: (stage: WorkspaceStage) => void;
12
12
  ariaLabel?: string;
13
+ statusLabels?: Partial<Record<WorkspaceStageStatus, string>>;
14
+ getStageAriaLabel?: (stage: WorkspaceStage) => string;
13
15
  class?: string;
14
16
  }
15
17
  declare const WorkspaceStageStepper: import("svelte").Component<Props, {}, "">;
@@ -50,6 +50,7 @@
50
50
  open?: boolean;
51
51
  side?: "left" | "right";
52
52
  onClose?: () => void;
53
+ closeLabel?: string;
53
54
  };
54
55
 
55
56
  let {
@@ -57,6 +58,7 @@
57
58
  open = $bindable(false),
58
59
  side = "right",
59
60
  onClose,
61
+ closeLabel = "Close",
60
62
  class: className,
61
63
  tabindex = -1,
62
64
  children,
@@ -185,7 +187,7 @@
185
187
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
186
188
  <path d="M18 6 6 18"/><path d="m6 6 12 12"/>
187
189
  </svg>
188
- <span class="sr-only">Close</span>
190
+ <span class="sr-only">{closeLabel}</span>
189
191
  </button>
190
192
  </div>
191
193
  {/if}
@@ -4,6 +4,7 @@ type Props = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
4
4
  open?: boolean;
5
5
  side?: "left" | "right";
6
6
  onClose?: () => void;
7
+ closeLabel?: string;
7
8
  };
8
9
  declare const Sheet: import("svelte").Component<Props, {}, "open" | "ref">;
9
10
  type Sheet = ReturnType<typeof Sheet>;
@@ -6,4 +6,5 @@ import Footer from "./table-footer.svelte";
6
6
  import Head from "./table-head.svelte";
7
7
  import Header from "./table-header.svelte";
8
8
  import Row from "./table-row.svelte";
9
+ export type { TableDensity } from "./table.svelte";
9
10
  export { Root, Body, Caption, Cell, Footer, Head, Header, Row, Root as Table, Body as TableBody, Caption as TableCaption, Cell as TableCell, Footer as TableFooter, Head as TableHead, Header as TableHeader, Row as TableRow, };
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
+ import { getContext } from "svelte";
2
3
  import { cn, type WithElementRef } from "../../../utils.js";
3
4
  import type { HTMLTdAttributes } from "svelte/elements";
5
+ import type { TableDensity } from "./table.svelte";
4
6
 
5
7
  let {
6
8
  ref = $bindable(null),
@@ -8,8 +10,13 @@
8
10
  children,
9
11
  ...restProps
10
12
  }: WithElementRef<HTMLTdAttributes, HTMLTableCellElement> = $props();
13
+
14
+ const getDensity = getContext<() => TableDensity>("svadmin-table-density");
15
+ const densityClass = $derived(getDensity?.() === "compact"
16
+ ? "h-8 px-2 py-1 text-xs"
17
+ : "p-2");
11
18
  </script>
12
19
 
13
- <td bind:this={ref} data-slot="table-cell" class={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
20
+ <td bind:this={ref} data-slot="table-cell" class={cn("align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", densityClass, className)} {...restProps}>
14
21
  {@render children?.()}
15
22
  </td>
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
+ import { getContext } from "svelte";
2
3
  import { cn, type WithElementRef } from "../../../utils.js";
3
4
  import type { HTMLThAttributes } from "svelte/elements";
5
+ import type { TableDensity } from "./table.svelte";
4
6
 
5
7
  let {
6
8
  ref = $bindable(null),
@@ -8,8 +10,13 @@
8
10
  children,
9
11
  ...restProps
10
12
  }: WithElementRef<HTMLThAttributes, HTMLTableCellElement> = $props();
13
+
14
+ const getDensity = getContext<() => TableDensity>("svadmin-table-density");
15
+ const densityClass = $derived(getDensity?.() === "compact"
16
+ ? "h-8 px-2 py-1 text-xs"
17
+ : "h-9 px-2");
11
18
  </script>
12
19
 
13
- <th bind:this={ref} data-slot="table-head" class={cn("text-foreground h-9 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
20
+ <th bind:this={ref} data-slot="table-head" class={cn("text-foreground text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", densityClass, className)} {...restProps}>
14
21
  {@render children?.()}
15
22
  </th>
@@ -1,16 +1,26 @@
1
1
  <script lang="ts">
2
+ import { setContext } from "svelte";
2
3
  import type { HTMLTableAttributes } from "svelte/elements";
3
4
  import { cn, type WithElementRef } from "../../../utils.js";
4
5
 
6
+ export type TableDensity = "compact" | "comfortable";
7
+
8
+ type Props = WithElementRef<HTMLTableAttributes, HTMLTableElement> & {
9
+ density?: TableDensity;
10
+ };
11
+
5
12
  let {
6
13
  ref = $bindable(null),
14
+ density = "comfortable",
7
15
  class: className,
8
16
  children,
9
17
  ...restProps
10
- }: WithElementRef<HTMLTableAttributes, HTMLTableElement> = $props();
18
+ }: Props = $props();
19
+
20
+ setContext("svadmin-table-density", () => density);
11
21
  </script>
12
22
 
13
- <div data-slot="table-container" class="relative w-full overflow-x-auto">
23
+ <div data-slot="table-container" data-table-density={density} class="relative w-full overflow-x-auto">
14
24
  <table bind:this={ref} data-slot="table" class={cn("w-full caption-bottom text-sm", className)} {...restProps}>
15
25
  {@render children?.()}
16
26
  </table>
@@ -1,5 +1,9 @@
1
1
  import type { HTMLTableAttributes } from "svelte/elements";
2
2
  import { type WithElementRef } from "../../../utils.js";
3
- declare const Table: import("svelte").Component<WithElementRef<HTMLTableAttributes, HTMLTableElement>, {}, "ref">;
3
+ export type TableDensity = "compact" | "comfortable";
4
+ type Props = WithElementRef<HTMLTableAttributes, HTMLTableElement> & {
5
+ density?: TableDensity;
6
+ };
7
+ declare const Table: import("svelte").Component<Props, {}, "ref">;
4
8
  type Table = ReturnType<typeof Table>;
5
9
  export default Table;
@@ -49,6 +49,8 @@ const ROUTES = [
49
49
  '/:resource/edit/:id',
50
50
  '/:resource/show/:id',
51
51
  '/:resource/clone/:id',
52
+ '/:resource/:id/edit',
53
+ '/:resource/:id',
52
54
  '/:parent/:parentId/:resource',
53
55
  '/:parent/:parentId/:resource/create',
54
56
  '/:parent/:parentId/:resource/edit/:id',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.49.0",
3
+ "version": "0.49.2",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -39,7 +39,7 @@
39
39
  "./components/*": "./dist/components/*"
40
40
  },
41
41
  "peerDependencies": {
42
- "@svadmin/core": "^0.39.0",
42
+ "@svadmin/core": "^0.40.1",
43
43
  "@tanstack/svelte-query": "^6.1.38",
44
44
  "highlight.js": "^11.11.1",
45
45
  "isomorphic-dompurify": "^3.22.0",