@svadmin/ui 0.13.0 → 0.15.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.15.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
package/src/app.css CHANGED
@@ -154,7 +154,16 @@ button:active:not(:disabled) {
154
154
  border-color: var(--color-primary);
155
155
  }
156
156
 
157
- /* Scrollbar styling */
157
+ /* Base scrollbar styling (Firefox + Standard) */
158
+ * {
159
+ scrollbar-width: thin;
160
+ scrollbar-color: oklch(0.8 0 0) transparent;
161
+ }
162
+ .dark * {
163
+ scrollbar-color: oklch(0.35 0 0) transparent;
164
+ }
165
+
166
+ /* Scrollbar styling (Webkit) */
158
167
  ::-webkit-scrollbar { width: 6px; height: 6px; }
159
168
  ::-webkit-scrollbar-track { background: transparent; }
160
169
  ::-webkit-scrollbar-thumb { background: oklch(0.8 0 0); border-radius: 3px; }
@@ -166,9 +175,11 @@ button:active:not(:disabled) {
166
175
  [data-slot="card"] {
167
176
  transition: box-shadow 0.2s ease, transform 0.2s ease;
168
177
  }
169
- [data-slot="card"]:hover {
170
- box-shadow: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 8px 24px -6px rgb(0 0 0 / 0.04);
171
- transform: translateY(-1px);
178
+ @media (hover: hover) {
179
+ [data-slot="card"]:hover {
180
+ box-shadow: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 8px 24px -6px rgb(0 0 0 / 0.04);
181
+ transform: translateY(-1px);
182
+ }
172
183
  }
173
184
 
174
185
  /* ② Table Row Hover — primary color tint instead of plain gray */
@@ -302,6 +313,13 @@ button:active:not(:disabled) {
302
313
  }
303
314
  }
304
315
 
316
+ /* ⑫b Safe Area Inset — iPhone notch / Home Bar support */
317
+ @supports (padding-bottom: env(safe-area-inset-bottom)) {
318
+ main {
319
+ padding-bottom: env(safe-area-inset-bottom);
320
+ }
321
+ }
322
+
305
323
  /* ⑫ Optimistic Delete — row fade-out visual feedback */
306
324
  @keyframes svadmin-row-delete {
307
325
  from {
@@ -372,6 +390,25 @@ main {
372
390
  margin-right: 4rem;
373
391
  }
374
392
 
393
+ /* ⑰ Accessibility Minimum Touch Target — Apple HIG 44px minimum */
394
+ @media (max-width: 640px) and (pointer: coarse) {
395
+ [data-slot="button"] {
396
+ position: relative;
397
+ }
398
+ [data-slot="button"]::after {
399
+ content: '';
400
+ position: absolute;
401
+ top: 50%;
402
+ left: 50%;
403
+ transform: translate(-50%, -50%);
404
+ min-width: 44px;
405
+ min-height: 44px;
406
+ width: 100%;
407
+ height: 100%;
408
+ z-index: 1;
409
+ }
410
+ }
411
+
375
412
  /* Tailwind CSS v4 — Map design tokens to utility classes */
376
413
  @theme {
377
414
  --color-background: var(--background);
@@ -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 === '/'}
@@ -256,7 +256,7 @@
256
256
  <Card.Title class="text-lg">{group.name}</Card.Title>
257
257
  </Card.Header>
258
258
  {/if}
259
- <Card.Content class="space-y-5">
259
+ <Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
260
260
  {#each group.fields as field (field.key)}
261
261
  <div class:border-destructive={!!fieldErrors[field.key]}>
262
262
  {#if fieldRenderer}
@@ -278,7 +278,7 @@
278
278
  {/each}
279
279
  {:else}
280
280
  <Card.Root>
281
- <Card.Content class="space-y-5">
281
+ <Card.Content class="space-y-5 px-4 sm:px-6 pb-4 sm:pb-6 pt-0">
282
282
  {#each formFields as field (field.key)}
283
283
  <div class:border-destructive={!!fieldErrors[field.key]}>
284
284
  {#if fieldRenderer}
@@ -335,9 +335,9 @@
335
335
 
336
336
  <div class="space-y-4">
337
337
  <!-- Header -->
338
- <div class="flex items-center justify-between">
339
- <h1 class="text-xl font-semibold text-foreground">{resource.label}</h1>
340
- <div class="flex items-center gap-2">
338
+ <div class="flex flex-wrap items-center justify-between gap-2">
339
+ <h1 class="text-lg sm:text-xl font-semibold text-foreground">{resource.label}</h1>
340
+ <div class="flex flex-wrap items-center gap-2">
341
341
  {#if canExport}
342
342
  <Button variant="outline" size="sm" onclick={exportCSV}>
343
343
  <Download class="h-4 w-4" data-icon="inline-start" /> {t('common.export')}
@@ -723,11 +723,11 @@
723
723
 
724
724
  <!-- Pagination (shadcn) -->
725
725
  {#if totalPages > 0}
726
- <div class="flex items-center justify-between text-sm text-muted-foreground">
726
+ <div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
727
727
  <div class="flex items-center gap-2">
728
728
  <span>{t('common.total', { total: query.data?.total ?? 0 })}</span>
729
729
  <Select
730
- class="h-8 w-auto text-xs"
730
+ class="hidden sm:inline-block h-8 w-auto text-xs"
731
731
  value={String(pagination.pageSize ?? 10)}
732
732
  onchange={(e: Event) => {
733
733
  const size = Number((e.target as HTMLSelectElement).value);
@@ -260,7 +260,7 @@
260
260
  tooltip={t('chat.title') || 'AI Assistant'}
261
261
  variant="default"
262
262
  size="icon"
263
- class="fixed bottom-6 right-6 z-[9998] h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 transition-all bg-primary text-primary-foreground"
263
+ class="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-[9998] h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 transition-all bg-primary text-primary-foreground"
264
264
  onclick={() => { open = true; minimized = false; }}
265
265
  >
266
266
  <MessageCircle class="h-5 w-5" />
@@ -276,9 +276,11 @@
276
276
  <!-- Chat Panel -->
277
277
  {#if open}
278
278
  <div
279
- class="fixed bottom-6 right-6 z-[9998] flex flex-col rounded-2xl border bg-card shadow-2xl overflow-hidden"
280
- class:w-[400px]={!minimized}
281
- class:h-[560px]={!minimized}
279
+ class="fixed z-[9998] flex flex-col rounded-2xl border bg-card shadow-2xl overflow-hidden bottom-3 right-3 sm:bottom-6 sm:right-6"
280
+ class:w-[calc(100vw-1.5rem)]={!minimized}
281
+ class:sm:w-[400px]={!minimized}
282
+ class:h-[calc(100dvh-6rem)]={!minimized}
283
+ class:sm:h-[560px]={!minimized}
282
284
  class:w-[280px]={minimized}
283
285
  class:h-auto={minimized}
284
286
  transition:fly={{ y: 300, duration: 250 }}
@@ -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;
@@ -62,12 +62,42 @@
62
62
  }
63
63
 
64
64
  let collapsed = $state(false);
65
+
66
+ // Swipe gesture for mobile menu
67
+ let touchStartX = $state(0);
68
+ let touchEndX = $state(0);
69
+
70
+ function handleTouchStart(e: TouchEvent) {
71
+ if (e.touches.length === 1) {
72
+ touchStartX = e.touches[0].clientX;
73
+ touchEndX = e.touches[0].clientX;
74
+ }
75
+ }
76
+
77
+ function handleTouchMove(e: TouchEvent) {
78
+ if (e.touches.length === 1) {
79
+ touchEndX = e.touches[0].clientX;
80
+ }
81
+ }
82
+
83
+ function handleTouchEnd() {
84
+ // Only trigger swipe-to-open if starting near the left edge (e.g., within 30px)
85
+ // and swiping right by at least 50px
86
+ if (touchStartX < 30 && touchEndX - touchStartX > 50) {
87
+ mobileMenuOpen = true;
88
+ }
89
+ }
65
90
  </script>
66
91
 
67
- <svelte:window onkeydown={(e) => {
68
- if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; }
69
- if (e.key === '?' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName)) { e.preventDefault(); shortcutsOpen = true; }
70
- }} />
92
+ <svelte:window
93
+ onkeydown={(e) => {
94
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; }
95
+ if (e.key === '?' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName)) { e.preventDefault(); shortcutsOpen = true; }
96
+ }}
97
+ ontouchstart={handleTouchStart}
98
+ ontouchmove={handleTouchMove}
99
+ ontouchend={handleTouchEnd}
100
+ />
71
101
  {#if loading}
72
102
  <div class="flex h-screen" in:fade={{ duration: 150 }}>
73
103
  <div class="hidden md:block w-64 bg-sidebar/80 p-4 space-y-4">
@@ -91,13 +121,13 @@
91
121
  <div class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
92
122
  <!-- Desktop sidebar -->
93
123
  <div class="hidden md:block">
94
- <Sidebar {collapsed} {identity} {title} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
124
+ <Sidebar {collapsed} {identity} {title} {menu} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
95
125
  </div>
96
126
 
97
127
  <!-- Mobile sidebar via Sheet -->
98
128
  <Sheet.Root bind:open={mobileMenuOpen} side="left">
99
129
  <div class="md:hidden">
100
- <Sidebar collapsed={false} {identity} {title} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
130
+ <Sidebar collapsed={false} {identity} {title} {menu} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
101
131
  </div>
102
132
  </Sheet.Root>
103
133
 
@@ -1,15 +1,16 @@
1
1
  <script lang="ts">
2
2
  // @ts-expect-error - missing types
3
- import { Marked } from 'marked';
3
+ import { Marked } from "marked";
4
4
  // @ts-expect-error - missing types
5
- import { markedHighlight } from 'marked-highlight';
5
+ import { markedHighlight } from "marked-highlight";
6
6
  // @ts-expect-error - missing types
7
- import hljs from 'highlight.js';
8
- import 'highlight.js/styles/github-dark.css'; // or your preferred theme
7
+ import * as hljsModule from "highlight.js";
8
+ const hljs = "default" in hljsModule ? hljsModule.default : hljsModule;
9
+ import "highlight.js/styles/github-dark.css"; // or your preferred theme
9
10
  // @ts-expect-error - missing types
10
- import DOMPurify from 'isomorphic-dompurify';
11
- import { Check, Copy } from 'lucide-svelte';
12
- import { Button } from './ui/button/index.js';
11
+ import DOMPurify from "isomorphic-dompurify";
12
+ import { Check, Copy } from "lucide-svelte";
13
+ import { Button } from "./ui/button/index.js";
13
14
 
14
15
  interface Props {
15
16
  /** The raw markdown string to render */
@@ -20,39 +21,41 @@
20
21
  class?: string;
21
22
  }
22
23
 
23
- let { content, streaming = false, class: className = '' }: Props = $props();
24
+ let { content, streaming = false, class: className = "" }: Props = $props();
24
25
 
25
26
  // Configure marked with syntax highlighting
26
27
  const marked = new Marked(
27
28
  markedHighlight({
28
- langPrefix: 'hljs language-',
29
+ langPrefix: "hljs language-",
29
30
  highlight(code: string, lang: string) {
30
- const language = hljs.getLanguage(lang) ? lang : 'plaintext';
31
+ const language = hljs.getLanguage(lang) ? lang : "plaintext";
31
32
  return hljs.highlight(code, { language }).value;
32
- }
33
- })
33
+ },
34
+ }),
34
35
  );
35
36
 
36
37
  // Render HTML safely
37
- const html = $derived(DOMPurify.sanitize(marked.parse(content || '') as string));
38
+ const html = $derived(
39
+ DOMPurify.sanitize(marked.parse(content || "") as string),
40
+ );
38
41
 
39
42
  // Handle copy code blocks
40
43
  let copiedBlock = $state<string | null>(null);
41
44
 
42
45
  function handleCopy(event: MouseEvent) {
43
46
  const target = event.target as HTMLElement;
44
- const button = target.closest('.copy-btn');
47
+ const button = target.closest(".copy-btn");
45
48
  if (!button) return;
46
49
 
47
- const pre = button.closest('.code-block-wrapper')?.querySelector('pre');
50
+ const pre = button.closest(".code-block-wrapper")?.querySelector("pre");
48
51
  if (!pre) return;
49
52
 
50
- const code = pre.textContent || '';
53
+ const code = pre.textContent || "";
51
54
  navigator.clipboard.writeText(code);
52
55
 
53
56
  const id = Math.random().toString(36);
54
57
  copiedBlock = id;
55
- button.setAttribute('data-copied-id', id);
58
+ button.setAttribute("data-copied-id", id);
56
59
 
57
60
  setTimeout(() => {
58
61
  if (copiedBlock === id) copiedBlock = null;
@@ -65,48 +68,51 @@
65
68
  return {
66
69
  update(newHtml: string) {
67
70
  // Find all pre > code blocks that don't have wrappers yet
68
- const pres = node.querySelectorAll('pre:not(.enhanced)');
69
- pres.forEach(pre => {
70
- pre.classList.add('enhanced');
71
- const wrapper = document.createElement('div');
72
- wrapper.className = 'code-block-wrapper group relative my-4 rounded-md bg-foreground/95';
73
-
74
- const header = document.createElement('div');
75
- header.className = 'flex items-center justify-between px-4 py-2 text-xs text-zinc-400 border-b border-zinc-800';
76
-
77
- const lang = Array.from(pre.querySelector('code')?.classList || []).find(c => c.startsWith('language-'))?.replace('language-', '') || 'Code';
71
+ const pres = node.querySelectorAll("pre:not(.enhanced)");
72
+ pres.forEach((pre) => {
73
+ pre.classList.add("enhanced");
74
+ const wrapper = document.createElement("div");
75
+ wrapper.className =
76
+ "code-block-wrapper group relative my-4 rounded-md bg-foreground/95";
77
+
78
+ const header = document.createElement("div");
79
+ header.className =
80
+ "flex items-center justify-between px-4 py-2 text-xs text-zinc-400 border-b border-zinc-800";
81
+
82
+ const lang =
83
+ Array.from(pre.querySelector("code")?.classList || [])
84
+ .find((c) => c.startsWith("language-"))
85
+ ?.replace("language-", "") || "Code";
78
86
  header.innerHTML = `
79
87
  <span>${lang}</span>
80
88
  <button class="copy-btn hover:text-white transition-colors flex items-center gap-1">
81
89
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
82
90
  </button>
83
91
  `;
84
-
92
+
85
93
  pre.replaceWith(wrapper);
86
94
  wrapper.appendChild(header);
87
-
88
- const scrollContainer = document.createElement('div');
89
- scrollContainer.className = 'overflow-x-auto p-4';
95
+
96
+ const scrollContainer = document.createElement("div");
97
+ scrollContainer.className = "overflow-x-auto p-4";
90
98
  scrollContainer.appendChild(pre);
91
99
  wrapper.appendChild(scrollContainer);
92
100
  });
93
101
 
94
102
  // Add event listener for copy buttons
95
- node.addEventListener('click', handleCopy);
103
+ node.addEventListener("click", handleCopy);
96
104
  },
97
105
  destroy() {
98
- node.removeEventListener('click', handleCopy);
99
- }
106
+ node.removeEventListener("click", handleCopy);
107
+ },
100
108
  };
101
109
  }
102
110
  </script>
103
111
 
104
112
  <div
105
- class="prose prose-sm dark:prose-invert max-w-none break-words {className}"
113
+ class="prose prose-sm dark:prose-invert max-w-none wrap-break-word {className}"
106
114
  class:streaming={streaming}
107
115
  use:enhanceCodeBlocks={html}
108
116
  >
109
117
  {@html html}
110
118
  </div>
111
-
112
-
@@ -39,9 +39,9 @@
39
39
  <Card.Root>
40
40
  <Card.Content class="divide-y divide-border p-0">
41
41
  {#each showFields.slice(0, 6) as _}
42
- <div class="flex px-6 py-4">
43
- <Skeleton class="h-4 w-1/4" />
44
- <Skeleton class="h-4 w-2/5 ml-auto" />
42
+ <div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4">
43
+ <Skeleton class="h-4 w-1/2 sm:w-1/4" />
44
+ <Skeleton class="h-4 w-3/4 sm:w-2/5 mt-1 sm:mt-0 sm:ml-auto" />
45
45
  </div>
46
46
  {/each}
47
47
  </Card.Content>
@@ -52,9 +52,9 @@
52
52
  {#each showFields as field}
53
53
  {@const value = (query.query.data!.data as Record<string, unknown>)[field.key]}
54
54
  {@const DisplayComponent = getDisplayComponent(field.type)}
55
- <div class="flex px-6 py-4">
56
- <div class="w-1/3 text-sm font-medium text-muted-foreground">{field.label}</div>
57
- <div class="w-2/3 text-sm text-foreground">
55
+ <div class="flex flex-col sm:flex-row px-4 sm:px-6 py-3 sm:py-4">
56
+ <div class="sm:w-1/3 text-xs sm:text-sm font-medium text-muted-foreground mb-1 sm:mb-0">{field.label}</div>
57
+ <div class="sm:w-2/3 text-sm text-foreground">
58
58
  {#if DisplayComponent && value != null}
59
59
  <DisplayComponent
60
60
  {value}
@@ -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}
@@ -4,16 +4,13 @@
4
4
  import type { Snippet } from 'svelte';
5
5
  import type { HTMLButtonAttributes } from 'svelte/elements';
6
6
 
7
- interface Props {
7
+ interface Props extends Omit<HTMLButtonAttributes, 'class' | 'children'> {
8
8
  tooltip: string;
9
9
  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
10
10
  size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
11
11
  side?: 'top' | 'right' | 'bottom' | 'left';
12
12
  class?: string;
13
13
  onclick?: (e: MouseEvent) => void;
14
- disabled?: boolean;
15
- type?: 'button' | 'submit' | 'reset';
16
- tabindex?: number;
17
14
  children: Snippet;
18
15
  }
19
16
 
@@ -28,6 +25,7 @@
28
25
  type = 'button',
29
26
  tabindex,
30
27
  children,
28
+ ...restProps
31
29
  }: Props = $props();
32
30
  </script>
33
31
 
@@ -36,10 +34,12 @@
36
34
  {#snippet child({ props })}
37
35
  <Button
38
36
  {...props}
37
+ {...restProps}
39
38
  {variant}
40
39
  {size}
41
40
  {type}
42
41
  class={className}
42
+ aria-label={restProps['aria-label'] ?? tooltip}
43
43
  {onclick}
44
44
  {disabled}
45
45
  {tabindex}
@@ -8,7 +8,7 @@
8
8
 
9
9
  <ol
10
10
  data-slot="breadcrumb-list"
11
- class={cn("text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", className)}
11
+ class={cn("text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5", className)}
12
12
  {...restProps}
13
13
  >
14
14
  {@render children?.()}