@classic-homes/theme-svelte 0.1.3 → 0.1.5
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/dist/lib/components/Combobox.svelte +187 -0
- package/dist/lib/components/Combobox.svelte.d.ts +38 -0
- package/dist/lib/components/DateTimePicker.svelte +415 -0
- package/dist/lib/components/DateTimePicker.svelte.d.ts +31 -0
- package/dist/lib/components/MultiSelect.svelte +244 -0
- package/dist/lib/components/MultiSelect.svelte.d.ts +40 -0
- package/dist/lib/components/NumberInput.svelte +205 -0
- package/dist/lib/components/NumberInput.svelte.d.ts +33 -0
- package/dist/lib/components/OTPInput.svelte +213 -0
- package/dist/lib/components/OTPInput.svelte.d.ts +23 -0
- package/dist/lib/components/RadioGroup.svelte +124 -0
- package/dist/lib/components/RadioGroup.svelte.d.ts +31 -0
- package/dist/lib/components/Signature.svelte +1070 -0
- package/dist/lib/components/Signature.svelte.d.ts +74 -0
- package/dist/lib/components/Slider.svelte +136 -0
- package/dist/lib/components/Slider.svelte.d.ts +30 -0
- package/dist/lib/components/layout/AppShell.svelte +1 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +63 -16
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +12 -10
- package/dist/lib/components/layout/QuickLinks.svelte +49 -29
- package/dist/lib/components/layout/QuickLinks.svelte.d.ts +4 -2
- package/dist/lib/components/layout/Sidebar.svelte +345 -86
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +12 -0
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +182 -0
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte.d.ts +18 -0
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +369 -0
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +25 -0
- package/dist/lib/components/layout/sidebar/SidebarSearch.svelte +121 -0
- package/dist/lib/components/layout/sidebar/SidebarSearch.svelte.d.ts +17 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +144 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +25 -0
- package/dist/lib/components/layout/sidebar/index.d.ts +10 -0
- package/dist/lib/components/layout/sidebar/index.js +10 -0
- package/dist/lib/index.d.ts +9 -1
- package/dist/lib/index.js +8 -0
- package/dist/lib/schemas/auth.d.ts +6 -6
- package/dist/lib/stores/sidebar.svelte.d.ts +54 -0
- package/dist/lib/stores/sidebar.svelte.js +171 -1
- package/dist/lib/types/components.d.ts +105 -0
- package/dist/lib/types/layout.d.ts +32 -2
- package/package.json +1 -1
|
@@ -15,12 +15,18 @@
|
|
|
15
15
|
import type { Snippet } from 'svelte';
|
|
16
16
|
import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
|
|
17
17
|
import { cn } from '../../utils.js';
|
|
18
|
+
import { sidebarStore } from '../../stores/sidebar.svelte.js';
|
|
18
19
|
import LogoMain from '../LogoMain.svelte';
|
|
19
20
|
import QuickLinks from './QuickLinks.svelte';
|
|
21
|
+
import SidebarSearch from './sidebar/SidebarSearch.svelte';
|
|
22
|
+
import SidebarSection from './sidebar/SidebarSection.svelte';
|
|
23
|
+
import SidebarFlyout from './sidebar/SidebarFlyout.svelte';
|
|
20
24
|
|
|
21
25
|
interface Props {
|
|
22
26
|
/** Navigation sections */
|
|
23
27
|
navigation: NavSection[];
|
|
28
|
+
/** User roles for filtering navigation items */
|
|
29
|
+
userRoles?: string[];
|
|
24
30
|
/** Whether sidebar is collapsed (desktop) */
|
|
25
31
|
collapsed?: boolean;
|
|
26
32
|
/** Whether currently on mobile */
|
|
@@ -29,6 +35,8 @@
|
|
|
29
35
|
mobileOpen?: boolean;
|
|
30
36
|
/** Callback when mobile sidebar should close */
|
|
31
37
|
onClose?: () => void;
|
|
38
|
+
/** Callback when a navigation item is clicked */
|
|
39
|
+
onNavigate?: (item: NavItem) => void;
|
|
32
40
|
/** Custom logo snippet */
|
|
33
41
|
logo?: Snippet;
|
|
34
42
|
/** Custom icon renderer for nav items */
|
|
@@ -45,16 +53,26 @@
|
|
|
45
53
|
variant?: 'light' | 'dark';
|
|
46
54
|
/** Use stronger/thicker border */
|
|
47
55
|
strongBorder?: boolean;
|
|
56
|
+
/** Sidebar width when expanded in pixels (default: 256) */
|
|
57
|
+
expandedWidth?: number;
|
|
58
|
+
/** Sidebar width when collapsed in pixels (default: 64) */
|
|
59
|
+
collapsedWidth?: number;
|
|
60
|
+
/** Enable search/filter input for navigation */
|
|
61
|
+
searchable?: boolean;
|
|
62
|
+
/** Placeholder text for search input */
|
|
63
|
+
searchPlaceholder?: string;
|
|
48
64
|
/** Additional classes */
|
|
49
65
|
class?: string;
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
let {
|
|
53
69
|
navigation,
|
|
70
|
+
userRoles,
|
|
54
71
|
collapsed = false,
|
|
55
72
|
isMobile = false,
|
|
56
73
|
mobileOpen = false,
|
|
57
74
|
onClose,
|
|
75
|
+
onNavigate,
|
|
58
76
|
logo,
|
|
59
77
|
icon,
|
|
60
78
|
quickLinks,
|
|
@@ -63,13 +81,269 @@
|
|
|
63
81
|
footer,
|
|
64
82
|
variant = 'light',
|
|
65
83
|
strongBorder = false,
|
|
84
|
+
expandedWidth = 256,
|
|
85
|
+
collapsedWidth = 64,
|
|
86
|
+
searchable = false,
|
|
87
|
+
searchPlaceholder = 'Search...',
|
|
66
88
|
class: className,
|
|
67
89
|
}: Props = $props();
|
|
68
90
|
|
|
91
|
+
// Helper to check if item is visible based on roles
|
|
92
|
+
function isItemVisible(item: NavItem): boolean {
|
|
93
|
+
if (!item.roles || item.roles.length === 0) return true;
|
|
94
|
+
if (!userRoles || userRoles.length === 0) return false;
|
|
95
|
+
return item.roles.some((role) => userRoles.includes(role));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Recursively filter items based on roles
|
|
99
|
+
function filterItems(items: NavItem[]): NavItem[] {
|
|
100
|
+
return items.filter(isItemVisible).map((item) => ({
|
|
101
|
+
...item,
|
|
102
|
+
children: item.children ? filterItems(item.children) : undefined,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Filter navigation based on user roles
|
|
107
|
+
const roleFilteredNavigation = $derived(
|
|
108
|
+
navigation
|
|
109
|
+
.map((section) => ({
|
|
110
|
+
...section,
|
|
111
|
+
items: filterItems(section.items),
|
|
112
|
+
}))
|
|
113
|
+
.filter((section) => section.items.length > 0)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Search state
|
|
117
|
+
let searchQuery = $state('');
|
|
118
|
+
|
|
119
|
+
// Filter items by search query
|
|
120
|
+
function filterItemsBySearch(items: NavItem[], query: string): NavItem[] {
|
|
121
|
+
return items
|
|
122
|
+
.filter((item) => {
|
|
123
|
+
const matches = item.name.toLowerCase().includes(query);
|
|
124
|
+
const childMatches = item.children?.some((child) =>
|
|
125
|
+
child.name.toLowerCase().includes(query)
|
|
126
|
+
);
|
|
127
|
+
return matches || childMatches;
|
|
128
|
+
})
|
|
129
|
+
.map((item) => ({
|
|
130
|
+
...item,
|
|
131
|
+
// Keep all children if parent matches, otherwise filter children
|
|
132
|
+
children: item.name.toLowerCase().includes(query)
|
|
133
|
+
? item.children
|
|
134
|
+
: item.children?.filter((child) => child.name.toLowerCase().includes(query)),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Combined filtered navigation (roles + search)
|
|
139
|
+
const filteredNavigation = $derived.by(() => {
|
|
140
|
+
if (!searchQuery.trim()) return roleFilteredNavigation;
|
|
141
|
+
const query = searchQuery.toLowerCase();
|
|
142
|
+
return roleFilteredNavigation
|
|
143
|
+
.map((section) => ({
|
|
144
|
+
...section,
|
|
145
|
+
items: filterItemsBySearch(section.items, query),
|
|
146
|
+
}))
|
|
147
|
+
.filter((section) => section.items.length > 0);
|
|
148
|
+
});
|
|
149
|
+
|
|
69
150
|
// Variant-based styling
|
|
70
151
|
const isLight = $derived(variant === 'light');
|
|
71
|
-
|
|
72
152
|
const sidebarWidth = $derived(collapsed ? 'w-16' : 'w-64');
|
|
153
|
+
|
|
154
|
+
// Track previous search to avoid redundant updates
|
|
155
|
+
let prevSearchQuery = '';
|
|
156
|
+
|
|
157
|
+
// Initialize expanded sections based on navigation data (only if no persisted state)
|
|
158
|
+
$effect(() => {
|
|
159
|
+
// Only run on initial mount (no search)
|
|
160
|
+
if (searchQuery.trim()) return;
|
|
161
|
+
|
|
162
|
+
// Set default expansions if no persisted state
|
|
163
|
+
const defaultExpanded: string[] = [];
|
|
164
|
+
roleFilteredNavigation.forEach((section) => {
|
|
165
|
+
// Expand by default unless explicitly set to collapsed
|
|
166
|
+
if (!section.collapsible || section.expanded !== false) {
|
|
167
|
+
defaultExpanded.push(section.id);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
sidebarStore.setDefaultSections(defaultExpanded);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Handle search-triggered expansions separately to avoid loops
|
|
174
|
+
$effect(() => {
|
|
175
|
+
const query = searchQuery.toLowerCase().trim();
|
|
176
|
+
|
|
177
|
+
// Only act when search query actually changes
|
|
178
|
+
if (query === prevSearchQuery) return;
|
|
179
|
+
prevSearchQuery = query;
|
|
180
|
+
|
|
181
|
+
if (query) {
|
|
182
|
+
// Force expand all sections during search
|
|
183
|
+
const allSectionIds = roleFilteredNavigation.map((section) => section.id);
|
|
184
|
+
sidebarStore.forceExpandSections(allSectionIds);
|
|
185
|
+
|
|
186
|
+
// Force expand items with matching children
|
|
187
|
+
const itemsToExpand: string[] = [];
|
|
188
|
+
|
|
189
|
+
const collectMatchingParents = (items: NavItem[]) => {
|
|
190
|
+
items.forEach((item) => {
|
|
191
|
+
if (item.children) {
|
|
192
|
+
const hasMatchingChild = item.children.some((child) =>
|
|
193
|
+
child.name.toLowerCase().includes(query)
|
|
194
|
+
);
|
|
195
|
+
if (hasMatchingChild) {
|
|
196
|
+
itemsToExpand.push(item.id);
|
|
197
|
+
}
|
|
198
|
+
collectMatchingParents(item.children);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
roleFilteredNavigation.forEach((section) => collectMatchingParents(section.items));
|
|
204
|
+
if (itemsToExpand.length > 0) {
|
|
205
|
+
sidebarStore.forceExpandItems(itemsToExpand);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Initialize expanded items based on item.expanded property (only if no persisted state)
|
|
211
|
+
$effect(() => {
|
|
212
|
+
// Only run on initial mount (no search)
|
|
213
|
+
if (searchQuery.trim()) return;
|
|
214
|
+
|
|
215
|
+
const defaultExpanded: string[] = [];
|
|
216
|
+
const collectExpanded = (items: NavItem[]) => {
|
|
217
|
+
items.forEach((item) => {
|
|
218
|
+
if (item.children && item.expanded) {
|
|
219
|
+
defaultExpanded.push(item.id);
|
|
220
|
+
}
|
|
221
|
+
if (item.children) {
|
|
222
|
+
collectExpanded(item.children);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
roleFilteredNavigation.forEach((section) => collectExpanded(section.items));
|
|
227
|
+
sidebarStore.setDefaultItems(defaultExpanded);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Flyout state for collapsed sidebar
|
|
231
|
+
let hoveredFlyoutItem = $state<NavItem | null>(null);
|
|
232
|
+
let flyoutTop = $state(0);
|
|
233
|
+
let flyoutCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
234
|
+
|
|
235
|
+
function handleItemMouseEnter(item: NavItem, event: MouseEvent) {
|
|
236
|
+
if (!collapsed || isMobile) return;
|
|
237
|
+
// Cancel any pending close
|
|
238
|
+
if (flyoutCloseTimer) {
|
|
239
|
+
clearTimeout(flyoutCloseTimer);
|
|
240
|
+
flyoutCloseTimer = null;
|
|
241
|
+
}
|
|
242
|
+
const target = event.currentTarget as HTMLElement;
|
|
243
|
+
const rect = target.getBoundingClientRect();
|
|
244
|
+
flyoutTop = rect.top;
|
|
245
|
+
hoveredFlyoutItem = item;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function handleItemMouseLeave() {
|
|
249
|
+
// Delay closing to allow mouse to move to flyout
|
|
250
|
+
flyoutCloseTimer = setTimeout(() => {
|
|
251
|
+
hoveredFlyoutItem = null;
|
|
252
|
+
flyoutCloseTimer = null;
|
|
253
|
+
}, 150);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function handleFlyoutMouseEnter() {
|
|
257
|
+
// Cancel the close timer when mouse enters flyout
|
|
258
|
+
if (flyoutCloseTimer) {
|
|
259
|
+
clearTimeout(flyoutCloseTimer);
|
|
260
|
+
flyoutCloseTimer = null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Focus management for mobile sidebar
|
|
265
|
+
let mobileCloseButton = $state<HTMLButtonElement | null>(null);
|
|
266
|
+
|
|
267
|
+
$effect(() => {
|
|
268
|
+
if (isMobile && mobileOpen && mobileCloseButton) {
|
|
269
|
+
// Focus the close button when mobile sidebar opens
|
|
270
|
+
mobileCloseButton.focus();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Wrap onNavigate to auto-close sidebar on mobile
|
|
275
|
+
function handleNavigate(item: NavItem) {
|
|
276
|
+
onNavigate?.(item);
|
|
277
|
+
// Auto-close on mobile after navigation
|
|
278
|
+
if (isMobile && mobileOpen) {
|
|
279
|
+
onClose?.();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Swipe-to-dismiss state for mobile
|
|
284
|
+
let touchStartX = $state(0);
|
|
285
|
+
let touchCurrentX = $state(0);
|
|
286
|
+
let isSwiping = $state(false);
|
|
287
|
+
|
|
288
|
+
function handleTouchStart(e: TouchEvent) {
|
|
289
|
+
if (!isMobile || !mobileOpen) return;
|
|
290
|
+
touchStartX = e.touches[0].clientX;
|
|
291
|
+
touchCurrentX = touchStartX;
|
|
292
|
+
isSwiping = true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function handleTouchMove(e: TouchEvent) {
|
|
296
|
+
if (!isSwiping) return;
|
|
297
|
+
touchCurrentX = e.touches[0].clientX;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function handleTouchEnd() {
|
|
301
|
+
if (!isSwiping) return;
|
|
302
|
+
const swipeDistance = touchStartX - touchCurrentX;
|
|
303
|
+
// Close if swiped left more than 100px
|
|
304
|
+
if (swipeDistance > 100) {
|
|
305
|
+
onClose?.();
|
|
306
|
+
}
|
|
307
|
+
isSwiping = false;
|
|
308
|
+
touchStartX = 0;
|
|
309
|
+
touchCurrentX = 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Calculate swipe transform offset
|
|
313
|
+
const swipeOffset = $derived(
|
|
314
|
+
isSwiping && touchCurrentX < touchStartX ? Math.min(0, touchCurrentX - touchStartX) : 0
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Navigation element reference for arrow key handling
|
|
318
|
+
let navElement = $state<HTMLElement | null>(null);
|
|
319
|
+
|
|
320
|
+
// Handle arrow key navigation within the sidebar
|
|
321
|
+
function handleNavKeydown(event: KeyboardEvent) {
|
|
322
|
+
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
|
|
323
|
+
|
|
324
|
+
// Get all focusable nav items (links and buttons) within the nav
|
|
325
|
+
if (!navElement) return;
|
|
326
|
+
const focusable = navElement.querySelectorAll<HTMLElement>(
|
|
327
|
+
'a[href]:not([disabled]):not([tabindex="-1"]), button:not([disabled]):not([tabindex="-1"])'
|
|
328
|
+
);
|
|
329
|
+
if (focusable.length === 0) return;
|
|
330
|
+
|
|
331
|
+
const focusableArray = Array.from(focusable);
|
|
332
|
+
const currentIndex = focusableArray.findIndex((el) => el === document.activeElement);
|
|
333
|
+
|
|
334
|
+
// Only handle if focus is within the nav
|
|
335
|
+
if (currentIndex === -1) return;
|
|
336
|
+
|
|
337
|
+
event.preventDefault();
|
|
338
|
+
|
|
339
|
+
if (event.key === 'ArrowDown') {
|
|
340
|
+
const nextIndex = currentIndex < focusableArray.length - 1 ? currentIndex + 1 : 0;
|
|
341
|
+
focusableArray[nextIndex].focus();
|
|
342
|
+
} else {
|
|
343
|
+
const prevIndex = currentIndex > 0 ? currentIndex - 1 : focusableArray.length - 1;
|
|
344
|
+
focusableArray[prevIndex].focus();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
73
347
|
</script>
|
|
74
348
|
|
|
75
349
|
<!-- Mobile Overlay: z-40 positions below sidebar (z-50) but above main content -->
|
|
@@ -87,19 +361,25 @@
|
|
|
87
361
|
<!-- Sidebar: z-50 positions above overlay and content, below modals (z-100+) -->
|
|
88
362
|
<aside
|
|
89
363
|
class={cn(
|
|
90
|
-
'fixed left-0 top-0 z-[var(--z-sidebar,50)] flex h-full flex-col transition-all duration-300',
|
|
364
|
+
'fixed left-0 top-0 z-[var(--z-sidebar,50)] flex h-full flex-col transition-all duration-300 touch-pan-y',
|
|
91
365
|
// Variant-based background and text colors
|
|
92
366
|
isLight ? 'bg-background text-foreground' : 'bg-sidebar text-sidebar-foreground',
|
|
93
367
|
strongBorder ? 'border-r-2 border-black' : 'border-r border-black',
|
|
94
368
|
isMobile ? (mobileOpen ? 'translate-x-0 w-64' : '-translate-x-full w-64') : sidebarWidth,
|
|
369
|
+
// Disable transition during swipe for smooth following
|
|
370
|
+
isSwiping && 'transition-none',
|
|
95
371
|
className
|
|
96
372
|
)}
|
|
373
|
+
style={isSwiping && swipeOffset < 0 ? `transform: translateX(${swipeOffset}px)` : undefined}
|
|
97
374
|
aria-label="Main navigation"
|
|
375
|
+
ontouchstart={handleTouchStart}
|
|
376
|
+
ontouchmove={handleTouchMove}
|
|
377
|
+
ontouchend={handleTouchEnd}
|
|
98
378
|
>
|
|
99
379
|
<!-- Logo Section -->
|
|
100
380
|
<div
|
|
101
381
|
class={cn(
|
|
102
|
-
'flex h-16 items-center border-b border-black px-4',
|
|
382
|
+
'flex h-16 items-center border-b border-black px-4 overflow-hidden',
|
|
103
383
|
collapsed && !isMobile && 'justify-center'
|
|
104
384
|
)}
|
|
105
385
|
>
|
|
@@ -114,90 +394,55 @@
|
|
|
114
394
|
{/if}
|
|
115
395
|
</div>
|
|
116
396
|
|
|
117
|
-
<!--
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
397
|
+
<!-- Search Input -->
|
|
398
|
+
{#if searchable && (!collapsed || isMobile)}
|
|
399
|
+
<SidebarSearch
|
|
400
|
+
value={searchQuery}
|
|
401
|
+
placeholder={searchPlaceholder}
|
|
402
|
+
{isLight}
|
|
403
|
+
onInput={(v) => (searchQuery = v)}
|
|
404
|
+
onClear={() => (searchQuery = '')}
|
|
405
|
+
/>
|
|
406
|
+
{/if}
|
|
407
|
+
|
|
408
|
+
<!-- Navigation - keydown for arrow key navigation is intentional for a11y -->
|
|
409
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
410
|
+
<nav bind:this={navElement} class="flex-1 overflow-y-auto py-4" onkeydown={handleNavKeydown}>
|
|
411
|
+
{#each filteredNavigation as section}
|
|
412
|
+
<SidebarSection
|
|
413
|
+
{section}
|
|
414
|
+
{isLight}
|
|
415
|
+
{collapsed}
|
|
416
|
+
{isMobile}
|
|
417
|
+
{icon}
|
|
418
|
+
{searchQuery}
|
|
419
|
+
onNavigate={handleNavigate}
|
|
420
|
+
onItemMouseEnter={handleItemMouseEnter}
|
|
421
|
+
onItemMouseLeave={handleItemMouseLeave}
|
|
422
|
+
/>
|
|
423
|
+
{:else}
|
|
424
|
+
{#if searchQuery.trim()}
|
|
425
|
+
<div class="px-4 py-8 text-center">
|
|
426
|
+
<svg
|
|
427
|
+
class="mx-auto h-8 w-8 text-muted-foreground/50 mb-2"
|
|
428
|
+
fill="none"
|
|
429
|
+
viewBox="0 0 24 24"
|
|
430
|
+
stroke="currentColor"
|
|
127
431
|
>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
'text-foreground hover:bg-accent hover:text-accent-foreground',
|
|
143
|
-
isLight && item.active && 'bg-primary/10 text-primary',
|
|
144
|
-
// Dark variant styling
|
|
145
|
-
!isLight &&
|
|
146
|
-
!item.active &&
|
|
147
|
-
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
148
|
-
!isLight && item.active && 'bg-sidebar-primary text-sidebar-primary-foreground',
|
|
149
|
-
collapsed && !isMobile && 'justify-center px-2'
|
|
150
|
-
)}
|
|
151
|
-
target={item.external ? '_blank' : undefined}
|
|
152
|
-
rel={item.external ? 'noopener noreferrer' : undefined}
|
|
153
|
-
aria-current={item.active ? 'page' : undefined}
|
|
154
|
-
title={collapsed && !isMobile ? item.name : undefined}
|
|
155
|
-
>
|
|
156
|
-
{#if icon}
|
|
157
|
-
<span class="h-5 w-5 shrink-0">
|
|
158
|
-
{@render icon(item)}
|
|
159
|
-
</span>
|
|
160
|
-
{:else if item.icon}
|
|
161
|
-
<span class="h-5 w-5 shrink-0 flex items-center justify-center">
|
|
162
|
-
<!-- Default icon placeholder - apps should provide icon snippet -->
|
|
163
|
-
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
164
|
-
<circle cx="12" cy="12" r="10" stroke-width="2" />
|
|
165
|
-
</svg>
|
|
166
|
-
</span>
|
|
167
|
-
{/if}
|
|
168
|
-
|
|
169
|
-
{#if !collapsed || isMobile}
|
|
170
|
-
<span class="flex-1 truncate">{item.name}</span>
|
|
171
|
-
|
|
172
|
-
{#if item.badge !== undefined}
|
|
173
|
-
<span
|
|
174
|
-
class="ml-auto rounded-full bg-primary px-2 py-0.5 text-xs font-medium text-primary-foreground"
|
|
175
|
-
>
|
|
176
|
-
{item.badge}
|
|
177
|
-
</span>
|
|
178
|
-
{/if}
|
|
179
|
-
|
|
180
|
-
{#if item.external}
|
|
181
|
-
<svg
|
|
182
|
-
class="h-4 w-4 opacity-50"
|
|
183
|
-
fill="none"
|
|
184
|
-
viewBox="0 0 24 24"
|
|
185
|
-
stroke="currentColor"
|
|
186
|
-
>
|
|
187
|
-
<path
|
|
188
|
-
stroke-linecap="round"
|
|
189
|
-
stroke-linejoin="round"
|
|
190
|
-
stroke-width="2"
|
|
191
|
-
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
|
192
|
-
/>
|
|
193
|
-
</svg>
|
|
194
|
-
{/if}
|
|
195
|
-
{/if}
|
|
196
|
-
</a>
|
|
197
|
-
</li>
|
|
198
|
-
{/each}
|
|
199
|
-
</ul>
|
|
200
|
-
</div>
|
|
432
|
+
<path
|
|
433
|
+
stroke-linecap="round"
|
|
434
|
+
stroke-linejoin="round"
|
|
435
|
+
stroke-width="2"
|
|
436
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
437
|
+
/>
|
|
438
|
+
</svg>
|
|
439
|
+
<p
|
|
440
|
+
class={cn('text-sm', isLight ? 'text-muted-foreground' : 'text-sidebar-foreground/60')}
|
|
441
|
+
>
|
|
442
|
+
No results for "{searchQuery}"
|
|
443
|
+
</p>
|
|
444
|
+
</div>
|
|
445
|
+
{/if}
|
|
201
446
|
{/each}
|
|
202
447
|
</nav>
|
|
203
448
|
|
|
@@ -223,8 +468,10 @@
|
|
|
223
468
|
<!-- Mobile Close Button -->
|
|
224
469
|
{#if isMobile}
|
|
225
470
|
<button
|
|
471
|
+
bind:this={mobileCloseButton}
|
|
226
472
|
class={cn(
|
|
227
473
|
'absolute right-2 top-2 rounded-md p-2',
|
|
474
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
|
228
475
|
isLight
|
|
229
476
|
? 'hover:bg-accent hover:text-accent-foreground'
|
|
230
477
|
: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
|
|
@@ -242,4 +489,16 @@
|
|
|
242
489
|
</svg>
|
|
243
490
|
</button>
|
|
244
491
|
{/if}
|
|
492
|
+
|
|
493
|
+
<!-- Flyout Menu for Collapsed State -->
|
|
494
|
+
{#if collapsed && !isMobile && hoveredFlyoutItem}
|
|
495
|
+
<SidebarFlyout
|
|
496
|
+
item={hoveredFlyoutItem}
|
|
497
|
+
top={flyoutTop}
|
|
498
|
+
{isLight}
|
|
499
|
+
onNavigate={handleNavigate}
|
|
500
|
+
onMouseEnter={handleFlyoutMouseEnter}
|
|
501
|
+
onMouseLeave={handleItemMouseLeave}
|
|
502
|
+
/>
|
|
503
|
+
{/if}
|
|
245
504
|
</aside>
|
|
@@ -16,6 +16,8 @@ import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
|
|
|
16
16
|
interface Props {
|
|
17
17
|
/** Navigation sections */
|
|
18
18
|
navigation: NavSection[];
|
|
19
|
+
/** User roles for filtering navigation items */
|
|
20
|
+
userRoles?: string[];
|
|
19
21
|
/** Whether sidebar is collapsed (desktop) */
|
|
20
22
|
collapsed?: boolean;
|
|
21
23
|
/** Whether currently on mobile */
|
|
@@ -24,6 +26,8 @@ interface Props {
|
|
|
24
26
|
mobileOpen?: boolean;
|
|
25
27
|
/** Callback when mobile sidebar should close */
|
|
26
28
|
onClose?: () => void;
|
|
29
|
+
/** Callback when a navigation item is clicked */
|
|
30
|
+
onNavigate?: (item: NavItem) => void;
|
|
27
31
|
/** Custom logo snippet */
|
|
28
32
|
logo?: Snippet;
|
|
29
33
|
/** Custom icon renderer for nav items */
|
|
@@ -40,6 +44,14 @@ interface Props {
|
|
|
40
44
|
variant?: 'light' | 'dark';
|
|
41
45
|
/** Use stronger/thicker border */
|
|
42
46
|
strongBorder?: boolean;
|
|
47
|
+
/** Sidebar width when expanded in pixels (default: 256) */
|
|
48
|
+
expandedWidth?: number;
|
|
49
|
+
/** Sidebar width when collapsed in pixels (default: 64) */
|
|
50
|
+
collapsedWidth?: number;
|
|
51
|
+
/** Enable search/filter input for navigation */
|
|
52
|
+
searchable?: boolean;
|
|
53
|
+
/** Placeholder text for search input */
|
|
54
|
+
searchPlaceholder?: string;
|
|
43
55
|
/** Additional classes */
|
|
44
56
|
class?: string;
|
|
45
57
|
}
|