@svadmin/ui 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -2,7 +2,7 @@
2
2
  // Auto-inject design tokens so consumers don't need to manually import
3
3
  import '../app.css';
4
4
  import type { Snippet } from 'svelte';
5
- import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider, ThemeConfig } from '@svadmin/core';
5
+ import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider, ThemeConfig, MenuItem } from '@svadmin/core';
6
6
  import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme, setRouterProvider, getAuthProvider, createHashRouterProvider, configureTheme } from '@svadmin/core';
7
7
  import { t } from '@svadmin/core/i18n';
8
8
  import { navigate } from '@svadmin/core/router';
@@ -41,6 +41,8 @@
41
41
  loginPage?: Snippet;
42
42
  /** Override default components via DI */
43
43
  components?: Partial<ComponentRegistry>;
44
+ /** Custom multi-level menu configuration */
45
+ menu?: MenuItem[];
44
46
  }
45
47
 
46
48
  let {
@@ -55,6 +57,7 @@
55
57
  dashboard,
56
58
  loginPage,
57
59
  components: userComponents,
60
+ menu,
58
61
  }: Props = $props();
59
62
 
60
63
  // Default component registry
@@ -143,7 +146,7 @@
143
146
  {:else if route === '/login' || route === '/register' || route === '/forgot-password' || route === '/update-password'}
144
147
  <ConfigErrorScreen title="{title} — {t('common.configRequired')}" />
145
148
  {:else if isAuthenticated || !authProvider}
146
- <Layout {title}>
149
+ <Layout {title} {menu}>
147
150
  {#key route + (params.resource ?? '') + (params.id ?? '')}
148
151
  <div class="svadmin-page-enter">
149
152
  {#if route === '/'}
@@ -10,7 +10,7 @@
10
10
  import TooltipButton from './TooltipButton.svelte';
11
11
  import { t } from '@svadmin/core/i18n';
12
12
  import { getAuthProvider } from '@svadmin/core';
13
- import type { Identity } from '@svadmin/core';
13
+ import type { Identity, MenuItem } from '@svadmin/core';
14
14
  import { navigate } from '@svadmin/core/router';
15
15
  import { currentPath } from '@svadmin/core/router';
16
16
  import { Skeleton } from './ui/skeleton/index.js';
@@ -22,7 +22,7 @@
22
22
  let shortcutsOpen = $state(false);
23
23
  let mobileMenuOpen = $state(false);
24
24
 
25
- let { children, title = 'Admin' }: { children: Snippet; title?: string } = $props();
25
+ let { children, title = 'Admin', menu }: { children: Snippet; title?: string; menu?: MenuItem[] } = $props();
26
26
 
27
27
  let hasAuth = true;
28
28
  let auth: ReturnType<typeof getAuthProvider> | null = null;
@@ -91,13 +91,13 @@
91
91
  <div class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
92
92
  <!-- Desktop sidebar -->
93
93
  <div class="hidden md:block">
94
- <Sidebar {collapsed} {identity} {title} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
94
+ <Sidebar {collapsed} {identity} {title} {menu} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
95
95
  </div>
96
96
 
97
97
  <!-- Mobile sidebar via Sheet -->
98
98
  <Sheet.Root bind:open={mobileMenuOpen} side="left">
99
99
  <div class="md:hidden">
100
- <Sidebar collapsed={false} {identity} {title} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
100
+ <Sidebar collapsed={false} {identity} {title} {menu} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
101
101
  </div>
102
102
  </Sheet.Root>
103
103
 
@@ -1,12 +1,13 @@
1
1
  <script lang="ts">
2
2
  import { getResources, canAccessAsync } from '@svadmin/core';
3
- import type { Identity } from '@svadmin/core';
3
+ import type { Identity, MenuItem } from '@svadmin/core';
4
4
  import { currentPath, navigate } from '@svadmin/core/router';
5
5
  import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
6
6
  import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
7
7
  import { Button } from './ui/button/index.js';
8
8
  import TooltipButton from './TooltipButton.svelte';
9
9
  import * as Tooltip from './ui/tooltip/index.js';
10
+ import SidebarItem from './SidebarItem.svelte';
10
11
 
11
12
  import { ScrollArea } from './ui/scroll-area/index.js';
12
13
  import * as Collapsible from './ui/collapsible/index.js';
@@ -16,13 +17,14 @@
16
17
  ChevronLeft, ChevronDown, LogOut, Sun, Moon, Palette
17
18
  } from 'lucide-svelte';
18
19
 
19
- let { collapsed, identity, title, onToggle, onLogout } = $props<{
20
+ let { collapsed, identity, title, onToggle, onLogout, menu }: {
20
21
  collapsed: boolean;
21
22
  identity: Identity | null;
22
23
  title: string;
23
24
  onToggle: () => void;
24
25
  onLogout: () => void;
25
- }>();
26
+ menu?: MenuItem[];
27
+ } = $props();
26
28
 
27
29
  const resources = getResources();
28
30
 
@@ -176,7 +178,14 @@
176
178
  </div>
177
179
 
178
180
  <ScrollArea class="flex-1">
179
- <nav aria-label="Main menu" class="py-4 px-3 space-y-4">
181
+ <nav aria-label="Main menu" class="py-4 px-3 space-y-1">
182
+ {#if menu && menu.length > 0}
183
+ <!-- Custom multi-level menu -->
184
+ {#each menu as item}
185
+ <SidebarItem {item} currentPath={path} {collapsed} depth={0} />
186
+ {/each}
187
+ {:else}
188
+ <!-- Auto-generated menu from resources (fallback) -->
180
189
  {#each navGroups as group}
181
190
  {#if group.name && !collapsed}
182
191
  <!-- Grouped section with Collapsible -->
@@ -248,6 +257,7 @@
248
257
  {/each}
249
258
  {/if}
250
259
  {/each}
260
+ {/if}
251
261
  </nav>
252
262
  </ScrollArea>
253
263
 
@@ -0,0 +1,129 @@
1
+ <script lang="ts">
2
+ import type { MenuItem } from '@svadmin/core';
3
+ import { t } from '@svadmin/core/i18n';
4
+ import * as Collapsible from './ui/collapsible/index.js';
5
+ import * as Tooltip from './ui/tooltip/index.js';
6
+ import SidebarItem from './SidebarItem.svelte';
7
+ import {
8
+ LayoutDashboard, FileText, Users, Settings, Home,
9
+ ChevronDown, Folder, ExternalLink, type Icon as LucideIcon,
10
+ } from 'lucide-svelte';
11
+
12
+ let { item, currentPath, collapsed = false, depth = 0 }: {
13
+ item: MenuItem;
14
+ currentPath: string;
15
+ collapsed?: boolean;
16
+ depth?: number;
17
+ } = $props();
18
+
19
+ const iconMap: Record<string, typeof LayoutDashboard> = {
20
+ dashboard: LayoutDashboard,
21
+ posts: FileText,
22
+ users: Users,
23
+ settings: Settings,
24
+ home: Home,
25
+ folder: Folder,
26
+ };
27
+
28
+ function getIcon(name?: string): typeof LayoutDashboard {
29
+ if (!name) return depth === 0 ? Settings : Folder;
30
+ return iconMap[name] ?? Settings;
31
+ }
32
+
33
+ function getLabel(mi: MenuItem): string {
34
+ return mi.label ?? mi.name;
35
+ }
36
+
37
+ function isActive(itemHref: string | undefined): boolean {
38
+ if (!itemHref) return false;
39
+ if (itemHref === '/') return currentPath === '/';
40
+ return currentPath.startsWith(itemHref);
41
+ }
42
+
43
+ /** Check if any child (recursively) is active — used to auto-open parent groups */
44
+ function hasActiveChild(menuItem: MenuItem): boolean {
45
+ if (isActive(menuItem.href)) return true;
46
+ return menuItem.children?.some((c: MenuItem) => hasActiveChild(c)) ?? false;
47
+ }
48
+
49
+ const hasChildren = $derived(item.children && item.children.length > 0);
50
+ const active = $derived(isActive(item.href));
51
+ const childActive = $derived(hasActiveChild(item));
52
+ const isExternal = $derived(item.target === '_blank');
53
+
54
+ // Track open state — auto-open if a child is active
55
+ let isOpen = $state(false);
56
+ $effect(() => { if (childActive) isOpen = true; });
57
+
58
+ const Icon = $derived(getIcon(item.icon));
59
+ const label = $derived(getLabel(item));
60
+ </script>
61
+
62
+ {#if item.meta?.hidden}
63
+ <!-- hidden item, render nothing -->
64
+ {:else if hasChildren && !collapsed}
65
+ <!-- Parent node with children: render as collapsible group -->
66
+ <Collapsible.Root bind:open={isOpen}>
67
+ <Collapsible.Trigger
68
+ class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
69
+ {childActive
70
+ ? 'text-sidebar-foreground bg-sidebar-accent/30'
71
+ : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
72
+ style="padding-left: {12 + depth * 12}px"
73
+ >
74
+ <span class="flex items-center gap-3">
75
+ <Icon class="h-4 w-4 flex-shrink-0" />
76
+ <span>{label}</span>
77
+ </span>
78
+ <ChevronDown class="h-3.5 w-3.5 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}" />
79
+ </Collapsible.Trigger>
80
+ <Collapsible.Content>
81
+ <div class="mt-0.5 space-y-0.5" style="padding-left: {depth > 0 ? 0 : 0}px">
82
+ {#each item.children as child}
83
+ <SidebarItem item={child} {currentPath} {collapsed} depth={depth + 1} />
84
+ {/each}
85
+ </div>
86
+ </Collapsible.Content>
87
+ </Collapsible.Root>
88
+ {:else if collapsed}
89
+ <!-- Collapsed sidebar: icon-only with tooltip -->
90
+ <Tooltip.Root>
91
+ <Tooltip.Trigger>
92
+ {#snippet child({ props }: { props: Record<string, unknown> })}
93
+ <a
94
+ {...props}
95
+ href={item.href ? `#${item.href}` : undefined}
96
+ target={isExternal ? '_blank' : undefined}
97
+ rel={isExternal ? 'noopener noreferrer' : undefined}
98
+ class="flex items-center justify-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
99
+ {active
100
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground'
101
+ : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
102
+ >
103
+ <Icon class="h-4 w-4 flex-shrink-0" />
104
+ </a>
105
+ {/snippet}
106
+ </Tooltip.Trigger>
107
+ <Tooltip.Content side="right">
108
+ {label}
109
+ </Tooltip.Content>
110
+ </Tooltip.Root>
111
+ {:else}
112
+ <!-- Leaf node: render as a link -->
113
+ <a
114
+ href={item.href ? `#${item.href}` : undefined}
115
+ target={isExternal ? '_blank' : undefined}
116
+ rel={isExternal ? 'noopener noreferrer' : undefined}
117
+ class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
118
+ {active
119
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
120
+ : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
121
+ style="padding-left: {12 + depth * 12}px"
122
+ >
123
+ <Icon class="h-4 w-4 flex-shrink-0" />
124
+ <span class="flex-1">{label}</span>
125
+ {#if isExternal}
126
+ <ExternalLink class="h-3 w-3 opacity-50" />
127
+ {/if}
128
+ </a>
129
+ {/if}