@classic-homes/theme-svelte 0.1.26 → 0.1.28
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/layout/DashboardLayout.svelte +26 -2
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +14 -0
- package/dist/lib/components/layout/Sidebar.svelte +106 -10
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +14 -0
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +193 -72
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +8 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +15 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +8 -0
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/stores/sidebar.svelte.d.ts +57 -1
- package/dist/lib/stores/sidebar.svelte.js +153 -13
- package/dist/lib/types/layout.d.ts +44 -0
- package/package.json +1 -1
|
@@ -79,12 +79,24 @@
|
|
|
79
79
|
searchable?: boolean;
|
|
80
80
|
/** Placeholder text for search input */
|
|
81
81
|
searchPlaceholder?: string;
|
|
82
|
+
/** Enable pinning feature for navigation items */
|
|
83
|
+
enablePinning?: boolean;
|
|
84
|
+
/** Title for the pinned items section (default: "Pinned") */
|
|
85
|
+
pinnedSectionTitle?: string;
|
|
86
|
+
/** Initial pinned item IDs for server hydration */
|
|
87
|
+
initialPinnedItems?: string[];
|
|
88
|
+
/** Callback when pinned items change (for external persistence) */
|
|
89
|
+
onPinnedChange?: (pinnedIds: string[]) => void;
|
|
90
|
+
/** Custom pin icon renderer */
|
|
91
|
+
pinIcon?: Snippet<[{ isPinned: boolean }]>;
|
|
82
92
|
/** Header search configuration */
|
|
83
93
|
headerSearch?: HeaderSearchConfig;
|
|
84
94
|
/** Breakpoint at which to switch between mobile and desktop layouts */
|
|
85
95
|
mobileBreakpoint?: Breakpoint;
|
|
86
96
|
/** Content area padding: 'default' (p-4 lg:p-6), 'compact' (p-2 lg:p-4), 'none' (no padding) */
|
|
87
97
|
contentPadding?: 'default' | 'compact' | 'none';
|
|
98
|
+
/** App identifier for per-app sidebar preferences storage */
|
|
99
|
+
appId?: string;
|
|
88
100
|
/** Main content */
|
|
89
101
|
children: Snippet;
|
|
90
102
|
}
|
|
@@ -112,9 +124,15 @@
|
|
|
112
124
|
collapsedWidth = 64,
|
|
113
125
|
searchable = false,
|
|
114
126
|
searchPlaceholder = 'Search...',
|
|
127
|
+
enablePinning = false,
|
|
128
|
+
pinnedSectionTitle = 'Pinned',
|
|
129
|
+
initialPinnedItems,
|
|
130
|
+
onPinnedChange,
|
|
131
|
+
pinIcon,
|
|
115
132
|
headerSearch,
|
|
116
133
|
mobileBreakpoint = 'lg',
|
|
117
134
|
contentPadding = 'default',
|
|
135
|
+
appId,
|
|
118
136
|
children,
|
|
119
137
|
}: Props = $props();
|
|
120
138
|
|
|
@@ -159,7 +177,7 @@
|
|
|
159
177
|
|
|
160
178
|
// Load persisted sidebar state and apply prop-based defaults
|
|
161
179
|
$effect(() => {
|
|
162
|
-
sidebarStore.initialize();
|
|
180
|
+
sidebarStore.initialize(appId);
|
|
163
181
|
|
|
164
182
|
// Only apply prop-based initial state if no persisted state exists
|
|
165
183
|
if (!sidebarStore.hasPersistedState && !isMobile) {
|
|
@@ -221,12 +239,18 @@
|
|
|
221
239
|
{icon}
|
|
222
240
|
{quickLinks}
|
|
223
241
|
{quickLinksDisplay}
|
|
224
|
-
{quickLinkIcon}
|
|
242
|
+
quickLinkIcon={quickLinkIcon ?? (icon as Snippet<[QuickLink]> | undefined)}
|
|
225
243
|
footer={sidebarFooter}
|
|
226
244
|
{expandedWidth}
|
|
227
245
|
{collapsedWidth}
|
|
228
246
|
{searchable}
|
|
229
247
|
{searchPlaceholder}
|
|
248
|
+
{enablePinning}
|
|
249
|
+
{pinnedSectionTitle}
|
|
250
|
+
{initialPinnedItems}
|
|
251
|
+
{onPinnedChange}
|
|
252
|
+
{pinIcon}
|
|
253
|
+
{appId}
|
|
230
254
|
/>
|
|
231
255
|
|
|
232
256
|
<!-- Main Content Area -->
|
|
@@ -57,12 +57,26 @@ interface Props {
|
|
|
57
57
|
searchable?: boolean;
|
|
58
58
|
/** Placeholder text for search input */
|
|
59
59
|
searchPlaceholder?: string;
|
|
60
|
+
/** Enable pinning feature for navigation items */
|
|
61
|
+
enablePinning?: boolean;
|
|
62
|
+
/** Title for the pinned items section (default: "Pinned") */
|
|
63
|
+
pinnedSectionTitle?: string;
|
|
64
|
+
/** Initial pinned item IDs for server hydration */
|
|
65
|
+
initialPinnedItems?: string[];
|
|
66
|
+
/** Callback when pinned items change (for external persistence) */
|
|
67
|
+
onPinnedChange?: (pinnedIds: string[]) => void;
|
|
68
|
+
/** Custom pin icon renderer */
|
|
69
|
+
pinIcon?: Snippet<[{
|
|
70
|
+
isPinned: boolean;
|
|
71
|
+
}]>;
|
|
60
72
|
/** Header search configuration */
|
|
61
73
|
headerSearch?: HeaderSearchConfig;
|
|
62
74
|
/** Breakpoint at which to switch between mobile and desktop layouts */
|
|
63
75
|
mobileBreakpoint?: Breakpoint;
|
|
64
76
|
/** Content area padding: 'default' (p-4 lg:p-6), 'compact' (p-2 lg:p-4), 'none' (no padding) */
|
|
65
77
|
contentPadding?: 'default' | 'compact' | 'none';
|
|
78
|
+
/** App identifier for per-app sidebar preferences storage */
|
|
79
|
+
appId?: string;
|
|
66
80
|
/** Main content */
|
|
67
81
|
children: Snippet;
|
|
68
82
|
}
|
|
@@ -61,6 +61,18 @@
|
|
|
61
61
|
searchable?: boolean;
|
|
62
62
|
/** Placeholder text for search input */
|
|
63
63
|
searchPlaceholder?: string;
|
|
64
|
+
/** Enable pinning feature for navigation items */
|
|
65
|
+
enablePinning?: boolean;
|
|
66
|
+
/** Title for the pinned items section (default: "Pinned") */
|
|
67
|
+
pinnedSectionTitle?: string;
|
|
68
|
+
/** Initial pinned item IDs for server hydration */
|
|
69
|
+
initialPinnedItems?: string[];
|
|
70
|
+
/** Callback when pinned items change (for external persistence) */
|
|
71
|
+
onPinnedChange?: (pinnedIds: string[]) => void;
|
|
72
|
+
/** Custom pin icon renderer */
|
|
73
|
+
pinIcon?: Snippet<[{ isPinned: boolean }]>;
|
|
74
|
+
/** App identifier for per-app preferences storage */
|
|
75
|
+
appId?: string;
|
|
64
76
|
/** Additional classes */
|
|
65
77
|
class?: string;
|
|
66
78
|
}
|
|
@@ -85,6 +97,12 @@
|
|
|
85
97
|
collapsedWidth = 64,
|
|
86
98
|
searchable = false,
|
|
87
99
|
searchPlaceholder = 'Search...',
|
|
100
|
+
enablePinning = false,
|
|
101
|
+
pinnedSectionTitle = 'Pinned',
|
|
102
|
+
initialPinnedItems,
|
|
103
|
+
onPinnedChange,
|
|
104
|
+
pinIcon,
|
|
105
|
+
appId,
|
|
88
106
|
class: className,
|
|
89
107
|
}: Props = $props();
|
|
90
108
|
|
|
@@ -113,6 +131,66 @@
|
|
|
113
131
|
.filter((section) => section.items.length > 0)
|
|
114
132
|
);
|
|
115
133
|
|
|
134
|
+
// Initialize store with appId for per-app preferences
|
|
135
|
+
$effect(() => {
|
|
136
|
+
sidebarStore.initialize(appId);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Initialize pinning from props
|
|
140
|
+
$effect(() => {
|
|
141
|
+
// Set up the callback for pinned changes
|
|
142
|
+
sidebarStore.setOnPinnedChange(onPinnedChange);
|
|
143
|
+
|
|
144
|
+
// Initialize from props if provided (server hydration)
|
|
145
|
+
if (initialPinnedItems?.length) {
|
|
146
|
+
sidebarStore.initializePinnedItems(initialPinnedItems);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Helper to find a nav item by ID (including nested children)
|
|
151
|
+
function findNavItemById(sections: NavSection[], itemId: string): NavItem | undefined {
|
|
152
|
+
for (const section of sections) {
|
|
153
|
+
for (const item of section.items) {
|
|
154
|
+
if (item.id === itemId) return item;
|
|
155
|
+
if (item.children) {
|
|
156
|
+
const found = item.children.find((child) => child.id === itemId);
|
|
157
|
+
if (found) return found;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Build pinned items section (only pinnable items that still exist in navigation)
|
|
165
|
+
const pinnedSection = $derived.by(() => {
|
|
166
|
+
if (!enablePinning) return null;
|
|
167
|
+
|
|
168
|
+
const pinnedIds = sidebarStore.getPinnedIds();
|
|
169
|
+
if (pinnedIds.length === 0) return null;
|
|
170
|
+
|
|
171
|
+
const pinnedItems: NavItem[] = [];
|
|
172
|
+
for (const id of pinnedIds) {
|
|
173
|
+
const item = findNavItemById(roleFilteredNavigation, id);
|
|
174
|
+
// Only include if item exists, is visible, and is pinnable
|
|
175
|
+
if (item && item.pinnable !== false) {
|
|
176
|
+
// Create a flattened copy (no children in pinned section)
|
|
177
|
+
pinnedItems.push({
|
|
178
|
+
...item,
|
|
179
|
+
children: undefined, // Flatten nested items
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (pinnedItems.length === 0) return null;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
id: '__pinned__',
|
|
188
|
+
title: pinnedSectionTitle,
|
|
189
|
+
items: pinnedItems,
|
|
190
|
+
collapsible: false,
|
|
191
|
+
} satisfies NavSection;
|
|
192
|
+
});
|
|
193
|
+
|
|
116
194
|
// Search state
|
|
117
195
|
let searchQuery = $state('');
|
|
118
196
|
|
|
@@ -155,19 +233,16 @@
|
|
|
155
233
|
// Track previous search to avoid redundant updates
|
|
156
234
|
let prevSearchQuery = '';
|
|
157
235
|
|
|
158
|
-
// Initialize expanded sections based on navigation data (only if no persisted state)
|
|
236
|
+
// Initialize expanded sections based on navigation data and user roles (only if no persisted state)
|
|
159
237
|
$effect(() => {
|
|
160
238
|
// Only run on initial mount (no search)
|
|
161
239
|
if (searchQuery.trim()) return;
|
|
162
240
|
|
|
163
|
-
//
|
|
164
|
-
const defaultExpanded
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
defaultExpanded.push(section.id);
|
|
169
|
-
}
|
|
170
|
-
});
|
|
241
|
+
// Use computeInitialExpansions for role-based section expansion
|
|
242
|
+
const defaultExpanded = sidebarStore.computeInitialExpansions(
|
|
243
|
+
roleFilteredNavigation,
|
|
244
|
+
userRoles
|
|
245
|
+
);
|
|
171
246
|
sidebarStore.setDefaultSections(defaultExpanded);
|
|
172
247
|
});
|
|
173
248
|
|
|
@@ -419,6 +494,24 @@
|
|
|
419
494
|
<!-- Navigation - keydown for arrow key navigation is intentional for a11y -->
|
|
420
495
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
421
496
|
<nav bind:this={navElement} class="flex-1 overflow-y-auto py-4" onkeydown={handleNavKeydown}>
|
|
497
|
+
<!-- Pinned Section (always at top, not affected by search) -->
|
|
498
|
+
{#if pinnedSection && !searchQuery.trim()}
|
|
499
|
+
<SidebarSection
|
|
500
|
+
section={pinnedSection}
|
|
501
|
+
{isLight}
|
|
502
|
+
{collapsed}
|
|
503
|
+
{isMobile}
|
|
504
|
+
{icon}
|
|
505
|
+
searchQuery=""
|
|
506
|
+
{enablePinning}
|
|
507
|
+
{pinIcon}
|
|
508
|
+
isPinnedSection={true}
|
|
509
|
+
onNavigate={handleNavigate}
|
|
510
|
+
onItemMouseEnter={handleItemMouseEnter}
|
|
511
|
+
onItemMouseLeave={handleItemMouseLeave}
|
|
512
|
+
/>
|
|
513
|
+
{/if}
|
|
514
|
+
|
|
422
515
|
{#each filteredNavigation as section}
|
|
423
516
|
<SidebarSection
|
|
424
517
|
{section}
|
|
@@ -427,6 +520,9 @@
|
|
|
427
520
|
{isMobile}
|
|
428
521
|
{icon}
|
|
429
522
|
{searchQuery}
|
|
523
|
+
{enablePinning}
|
|
524
|
+
{pinIcon}
|
|
525
|
+
isPinnedSection={false}
|
|
430
526
|
onNavigate={handleNavigate}
|
|
431
527
|
onItemMouseEnter={handleItemMouseEnter}
|
|
432
528
|
onItemMouseLeave={handleItemMouseLeave}
|
|
@@ -464,7 +560,7 @@
|
|
|
464
560
|
links={quickLinks}
|
|
465
561
|
display={collapsed && !isMobile ? 'icons' : quickLinksDisplay}
|
|
466
562
|
{variant}
|
|
467
|
-
icon={quickLinkIcon}
|
|
563
|
+
icon={quickLinkIcon ?? (icon as Snippet<[QuickLink]> | undefined)}
|
|
468
564
|
/>
|
|
469
565
|
</div>
|
|
470
566
|
{/if}
|
|
@@ -52,6 +52,20 @@ interface Props {
|
|
|
52
52
|
searchable?: boolean;
|
|
53
53
|
/** Placeholder text for search input */
|
|
54
54
|
searchPlaceholder?: string;
|
|
55
|
+
/** Enable pinning feature for navigation items */
|
|
56
|
+
enablePinning?: boolean;
|
|
57
|
+
/** Title for the pinned items section (default: "Pinned") */
|
|
58
|
+
pinnedSectionTitle?: string;
|
|
59
|
+
/** Initial pinned item IDs for server hydration */
|
|
60
|
+
initialPinnedItems?: string[];
|
|
61
|
+
/** Callback when pinned items change (for external persistence) */
|
|
62
|
+
onPinnedChange?: (pinnedIds: string[]) => void;
|
|
63
|
+
/** Custom pin icon renderer */
|
|
64
|
+
pinIcon?: Snippet<[{
|
|
65
|
+
isPinned: boolean;
|
|
66
|
+
}]>;
|
|
67
|
+
/** App identifier for per-app preferences storage */
|
|
68
|
+
appId?: string;
|
|
55
69
|
/** Additional classes */
|
|
56
70
|
class?: string;
|
|
57
71
|
}
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
icon?: Snippet<[NavItem]>;
|
|
28
28
|
/** Search query for text highlighting */
|
|
29
29
|
searchQuery?: string;
|
|
30
|
+
/** Enable pinning feature */
|
|
31
|
+
enablePinning?: boolean;
|
|
32
|
+
/** Custom pin icon renderer */
|
|
33
|
+
pinIcon?: Snippet<[{ isPinned: boolean }]>;
|
|
34
|
+
/** Whether this item is rendered in the pinned section */
|
|
35
|
+
isPinnedSection?: boolean;
|
|
30
36
|
/** Callback when item is clicked */
|
|
31
37
|
onNavigate?: (item: NavItem) => void;
|
|
32
38
|
/** Callback when mouse enters item (for flyout) */
|
|
@@ -42,6 +48,9 @@
|
|
|
42
48
|
isMobile = false,
|
|
43
49
|
icon,
|
|
44
50
|
searchQuery = '',
|
|
51
|
+
enablePinning = false,
|
|
52
|
+
pinIcon,
|
|
53
|
+
isPinnedSection = false,
|
|
45
54
|
onNavigate,
|
|
46
55
|
onMouseEnter,
|
|
47
56
|
onMouseLeave,
|
|
@@ -96,6 +105,21 @@
|
|
|
96
105
|
function handleMouseEnter(e: MouseEvent) {
|
|
97
106
|
onMouseEnter?.(item, e);
|
|
98
107
|
}
|
|
108
|
+
|
|
109
|
+
// Pinning logic
|
|
110
|
+
const canBePinned = $derived(
|
|
111
|
+
enablePinning && item.pinnable !== false && !isPinnedSection && !collapsed
|
|
112
|
+
);
|
|
113
|
+
const isPinned = $derived(sidebarStore.isPinned(item.id));
|
|
114
|
+
|
|
115
|
+
// In pinned section, show unpin button
|
|
116
|
+
const showUnpinButton = $derived(enablePinning && isPinnedSection && !collapsed);
|
|
117
|
+
|
|
118
|
+
function handlePinToggle(e: MouseEvent | KeyboardEvent) {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
e.stopPropagation();
|
|
121
|
+
sidebarStore.togglePin(item.id);
|
|
122
|
+
}
|
|
99
123
|
</script>
|
|
100
124
|
|
|
101
125
|
<!-- Reusable snippets -->
|
|
@@ -158,6 +182,66 @@
|
|
|
158
182
|
{/if}
|
|
159
183
|
{/snippet}
|
|
160
184
|
|
|
185
|
+
{#snippet pinButton(pinned: boolean, isUnpin: boolean = false)}
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
class={cn(
|
|
189
|
+
'flex items-center justify-center p-1 rounded transition-all shrink-0',
|
|
190
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
|
191
|
+
// Show on hover for regular items, always show in pinned section
|
|
192
|
+
isUnpin ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
|
193
|
+
// Light variant: white text when row is hovered (teal bg), red/primary text when active
|
|
194
|
+
isLight && !item.active && 'text-muted-foreground group-hover:text-white hover:bg-white/20',
|
|
195
|
+
isLight && item.active && 'text-primary hover:bg-primary/20',
|
|
196
|
+
// Dark variant: white text when row is hovered
|
|
197
|
+
!isLight &&
|
|
198
|
+
!item.active &&
|
|
199
|
+
'text-sidebar-foreground/60 group-hover:text-white hover:bg-white/20',
|
|
200
|
+
!isLight && item.active && 'text-sidebar-primary-foreground hover:bg-white/20'
|
|
201
|
+
)}
|
|
202
|
+
onclick={handlePinToggle}
|
|
203
|
+
onkeydown={(e) => e.key === 'Enter' && handlePinToggle(e)}
|
|
204
|
+
aria-label={pinned ? `Unpin ${item.name}` : `Pin ${item.name}`}
|
|
205
|
+
title={pinned ? `Unpin ${item.name}` : `Pin ${item.name}`}
|
|
206
|
+
>
|
|
207
|
+
{#if pinIcon}
|
|
208
|
+
{@render pinIcon({ isPinned: pinned })}
|
|
209
|
+
{:else if pinned}
|
|
210
|
+
<!-- Filled pin icon (pinned) -->
|
|
211
|
+
<svg
|
|
212
|
+
class="h-4 w-4"
|
|
213
|
+
viewBox="0 0 24 24"
|
|
214
|
+
fill="currentColor"
|
|
215
|
+
stroke="currentColor"
|
|
216
|
+
stroke-width="2"
|
|
217
|
+
stroke-linecap="round"
|
|
218
|
+
stroke-linejoin="round"
|
|
219
|
+
>
|
|
220
|
+
<line x1="12" x2="12" y1="17" y2="22" />
|
|
221
|
+
<path
|
|
222
|
+
d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"
|
|
223
|
+
/>
|
|
224
|
+
</svg>
|
|
225
|
+
{:else}
|
|
226
|
+
<!-- Outline pin icon (not pinned) -->
|
|
227
|
+
<svg
|
|
228
|
+
class="h-4 w-4"
|
|
229
|
+
viewBox="0 0 24 24"
|
|
230
|
+
fill="none"
|
|
231
|
+
stroke="currentColor"
|
|
232
|
+
stroke-width="2"
|
|
233
|
+
stroke-linecap="round"
|
|
234
|
+
stroke-linejoin="round"
|
|
235
|
+
>
|
|
236
|
+
<line x1="12" x2="12" y1="17" y2="22" />
|
|
237
|
+
<path
|
|
238
|
+
d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"
|
|
239
|
+
/>
|
|
240
|
+
</svg>
|
|
241
|
+
{/if}
|
|
242
|
+
</button>
|
|
243
|
+
{/snippet}
|
|
244
|
+
|
|
161
245
|
{#if hasChildren && !collapsed}
|
|
162
246
|
<!-- Item with children - expandable submenu -->
|
|
163
247
|
<div>
|
|
@@ -259,6 +343,13 @@
|
|
|
259
343
|
/>
|
|
260
344
|
</svg>
|
|
261
345
|
</button>
|
|
346
|
+
|
|
347
|
+
<!-- Pin button for items with children -->
|
|
348
|
+
{#if canBePinned}
|
|
349
|
+
{@render pinButton(isPinned, false)}
|
|
350
|
+
{:else if showUnpinButton}
|
|
351
|
+
{@render pinButton(true, true)}
|
|
352
|
+
{/if}
|
|
262
353
|
</div>
|
|
263
354
|
|
|
264
355
|
{#if isExpanded}
|
|
@@ -300,80 +391,110 @@
|
|
|
300
391
|
</div>
|
|
301
392
|
{:else}
|
|
302
393
|
<!-- Regular item or collapsed item -->
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
394
|
+
<!-- Wrap in group div for pin button hover behavior -->
|
|
395
|
+
<div
|
|
396
|
+
class={cn(
|
|
397
|
+
'group flex w-full items-center rounded-md transition-colors overflow-hidden',
|
|
398
|
+
isLight && !item.active && 'hover:bg-accent',
|
|
399
|
+
isLight && item.active && 'bg-primary/10',
|
|
400
|
+
!isLight && !item.active && 'hover:bg-sidebar-accent',
|
|
401
|
+
!isLight && item.active && 'bg-sidebar-primary'
|
|
402
|
+
)}
|
|
403
|
+
>
|
|
404
|
+
{#if item.href}
|
|
405
|
+
<!-- Item with href - render as link -->
|
|
406
|
+
<a
|
|
407
|
+
href={item.disabled ? undefined : item.href}
|
|
408
|
+
class={cn(
|
|
409
|
+
'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium transition-colors overflow-hidden relative',
|
|
410
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
|
411
|
+
isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
|
|
412
|
+
isLight && item.active && 'text-primary',
|
|
413
|
+
!isLight &&
|
|
414
|
+
!item.active &&
|
|
415
|
+
'text-sidebar-foreground group-hover:text-sidebar-accent-foreground',
|
|
416
|
+
!isLight && item.active && 'text-sidebar-primary-foreground',
|
|
417
|
+
item.disabled && 'opacity-50 pointer-events-none cursor-not-allowed',
|
|
418
|
+
collapsed && !isMobile && 'justify-center px-2'
|
|
419
|
+
)}
|
|
420
|
+
target={item.external ? '_blank' : undefined}
|
|
421
|
+
rel={item.external ? 'noopener noreferrer' : undefined}
|
|
422
|
+
aria-current={item.active ? 'page' : undefined}
|
|
423
|
+
aria-disabled={item.disabled || undefined}
|
|
424
|
+
tabindex={item.disabled ? -1 : undefined}
|
|
425
|
+
title={collapsed && !isMobile
|
|
426
|
+
? item.external
|
|
427
|
+
? `${item.name} (opens in new tab)`
|
|
428
|
+
: item.name
|
|
429
|
+
: undefined}
|
|
430
|
+
onclick={handleClick}
|
|
431
|
+
onmouseenter={handleMouseEnter}
|
|
432
|
+
onmouseleave={onMouseLeave}
|
|
433
|
+
>
|
|
434
|
+
{@render navIcon(item)}
|
|
328
435
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
436
|
+
{#if !collapsed || isMobile}
|
|
437
|
+
{@render highlightedName(item.name, itemHighlight)}
|
|
438
|
+
{@render navBadge(item.badge)}
|
|
439
|
+
{#if item.external}{@render externalIndicator()}{/if}
|
|
440
|
+
{/if}
|
|
334
441
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
442
|
+
<!-- Visual indicator for collapsed items with children -->
|
|
443
|
+
{#if collapsed && !isMobile && hasChildren}
|
|
444
|
+
<span class="absolute bottom-0.5 right-0.5">
|
|
445
|
+
<svg class="h-2 w-2 opacity-50" fill="currentColor" viewBox="0 0 8 8">
|
|
446
|
+
<path d="M0 0 L8 4 L0 8 Z" />
|
|
447
|
+
</svg>
|
|
448
|
+
</span>
|
|
449
|
+
{/if}
|
|
450
|
+
</a>
|
|
451
|
+
{:else}
|
|
452
|
+
<!-- Item without href (parent with children only, or action button) - render as button -->
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
class={cn(
|
|
456
|
+
'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium transition-colors overflow-hidden text-left relative',
|
|
457
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
|
458
|
+
isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
|
|
459
|
+
isLight && item.active && 'text-primary',
|
|
460
|
+
!isLight &&
|
|
461
|
+
!item.active &&
|
|
462
|
+
'text-sidebar-foreground group-hover:text-sidebar-accent-foreground',
|
|
463
|
+
!isLight && item.active && 'text-sidebar-primary-foreground',
|
|
464
|
+
item.disabled && 'opacity-50 pointer-events-none cursor-not-allowed',
|
|
465
|
+
collapsed && !isMobile && 'justify-center px-2'
|
|
466
|
+
)}
|
|
467
|
+
title={collapsed && !isMobile ? item.name : undefined}
|
|
468
|
+
aria-haspopup={hasChildren ? 'menu' : undefined}
|
|
469
|
+
aria-disabled={item.disabled || undefined}
|
|
470
|
+
disabled={item.disabled}
|
|
471
|
+
onclick={handleClick}
|
|
472
|
+
onmouseenter={handleMouseEnter}
|
|
473
|
+
onmouseleave={onMouseLeave}
|
|
474
|
+
>
|
|
475
|
+
{@render navIcon(item)}
|
|
363
476
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
477
|
+
{#if !collapsed || isMobile}
|
|
478
|
+
{@render highlightedName(item.name, itemHighlight)}
|
|
479
|
+
{@render navBadge(item.badge)}
|
|
480
|
+
{/if}
|
|
368
481
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
482
|
+
<!-- Visual indicator for collapsed items with children -->
|
|
483
|
+
{#if collapsed && !isMobile && hasChildren}
|
|
484
|
+
<span class="absolute bottom-0.5 right-0.5">
|
|
485
|
+
<svg class="h-2 w-2 opacity-50" fill="currentColor" viewBox="0 0 8 8">
|
|
486
|
+
<path d="M0 0 L8 4 L0 8 Z" />
|
|
487
|
+
</svg>
|
|
488
|
+
</span>
|
|
489
|
+
{/if}
|
|
490
|
+
</button>
|
|
491
|
+
{/if}
|
|
492
|
+
|
|
493
|
+
<!-- Pin button for regular items -->
|
|
494
|
+
{#if canBePinned}
|
|
495
|
+
{@render pinButton(isPinned, false)}
|
|
496
|
+
{:else if showUnpinButton}
|
|
497
|
+
{@render pinButton(true, true)}
|
|
498
|
+
{/if}
|
|
499
|
+
</div>
|
|
379
500
|
{/if}
|
|
@@ -13,6 +13,14 @@ interface Props {
|
|
|
13
13
|
icon?: Snippet<[NavItem]>;
|
|
14
14
|
/** Search query for text highlighting */
|
|
15
15
|
searchQuery?: string;
|
|
16
|
+
/** Enable pinning feature */
|
|
17
|
+
enablePinning?: boolean;
|
|
18
|
+
/** Custom pin icon renderer */
|
|
19
|
+
pinIcon?: Snippet<[{
|
|
20
|
+
isPinned: boolean;
|
|
21
|
+
}]>;
|
|
22
|
+
/** Whether this item is rendered in the pinned section */
|
|
23
|
+
isPinnedSection?: boolean;
|
|
16
24
|
/** Callback when item is clicked */
|
|
17
25
|
onNavigate?: (item: NavItem) => void;
|
|
18
26
|
/** Callback when mouse enters item (for flyout) */
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
icon?: Snippet<[NavItem]>;
|
|
25
25
|
/** Search query for text highlighting */
|
|
26
26
|
searchQuery?: string;
|
|
27
|
+
/** Enable pinning feature for navigation items */
|
|
28
|
+
enablePinning?: boolean;
|
|
29
|
+
/** Custom pin icon renderer */
|
|
30
|
+
pinIcon?: Snippet<[{ isPinned: boolean }]>;
|
|
31
|
+
/** Whether this is the special pinned section */
|
|
32
|
+
isPinnedSection?: boolean;
|
|
27
33
|
/** Callback when a navigation item is clicked */
|
|
28
34
|
onNavigate?: (item: NavItem) => void;
|
|
29
35
|
/** Callback when mouse enters an item (for flyout) */
|
|
@@ -39,6 +45,9 @@
|
|
|
39
45
|
isMobile = false,
|
|
40
46
|
icon,
|
|
41
47
|
searchQuery = '',
|
|
48
|
+
enablePinning = false,
|
|
49
|
+
pinIcon,
|
|
50
|
+
isPinnedSection = false,
|
|
42
51
|
onNavigate,
|
|
43
52
|
onItemMouseEnter,
|
|
44
53
|
onItemMouseLeave,
|
|
@@ -110,6 +119,9 @@
|
|
|
110
119
|
{isMobile}
|
|
111
120
|
{icon}
|
|
112
121
|
{searchQuery}
|
|
122
|
+
{enablePinning}
|
|
123
|
+
{pinIcon}
|
|
124
|
+
{isPinnedSection}
|
|
113
125
|
{onNavigate}
|
|
114
126
|
onMouseEnter={onItemMouseEnter}
|
|
115
127
|
onMouseLeave={onItemMouseLeave}
|
|
@@ -133,6 +145,9 @@
|
|
|
133
145
|
{isMobile}
|
|
134
146
|
{icon}
|
|
135
147
|
{searchQuery}
|
|
148
|
+
{enablePinning}
|
|
149
|
+
{pinIcon}
|
|
150
|
+
{isPinnedSection}
|
|
136
151
|
{onNavigate}
|
|
137
152
|
onMouseEnter={onItemMouseEnter}
|
|
138
153
|
onMouseLeave={onItemMouseLeave}
|
|
@@ -13,6 +13,14 @@ interface Props {
|
|
|
13
13
|
icon?: Snippet<[NavItem]>;
|
|
14
14
|
/** Search query for text highlighting */
|
|
15
15
|
searchQuery?: string;
|
|
16
|
+
/** Enable pinning feature for navigation items */
|
|
17
|
+
enablePinning?: boolean;
|
|
18
|
+
/** Custom pin icon renderer */
|
|
19
|
+
pinIcon?: Snippet<[{
|
|
20
|
+
isPinned: boolean;
|
|
21
|
+
}]>;
|
|
22
|
+
/** Whether this is the special pinned section */
|
|
23
|
+
isPinnedSection?: boolean;
|
|
16
24
|
/** Callback when a navigation item is clicked */
|
|
17
25
|
onNavigate?: (item: NavItem) => void;
|
|
18
26
|
/** Callback when mouse enters an item (for flyout) */
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export { default as Header } from './components/layout/Header.svelte';
|
|
|
66
66
|
export { default as Footer } from './components/layout/Footer.svelte';
|
|
67
67
|
export { default as QuickLinks } from './components/layout/QuickLinks.svelte';
|
|
68
68
|
export { default as HeaderSearch } from './components/HeaderSearch.svelte';
|
|
69
|
-
export type { NavItem, NavSection, User, BackLink, QuickLink, QuickLinksProps, DashboardLayoutProps, PublicLayoutProps, AuthLayoutProps, AuthFooterLink, ErrorLayoutProps, FormPageLayoutProps, SidebarProps, HeaderProps, FooterProps, AppShellProps, SearchResultItem, SearchResultGroup, HeaderSearchConfig, } from './types/layout.js';
|
|
69
|
+
export type { NavItem, NavSection, User, BackLink, QuickLink, QuickLinksProps, DashboardLayoutProps, PublicLayoutProps, AuthLayoutProps, AuthFooterLink, ErrorLayoutProps, FormPageLayoutProps, SidebarProps, SidebarPreferences, HeaderProps, FooterProps, AppShellProps, SearchResultItem, SearchResultGroup, HeaderSearchConfig, } from './types/layout.js';
|
|
70
70
|
export { toastStore, type Toast as ToastType, type ToastInput } from './stores/toast.svelte.js';
|
|
71
71
|
export { sidebarStore } from './stores/sidebar.svelte.js';
|
|
72
72
|
export { themeStore, type ThemeMode } from './stores/theme.svelte.js';
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sidebar Store - Svelte 5 runes-based state management for sidebar
|
|
3
|
+
*
|
|
4
|
+
* Supports per-app preferences via appId parameter. When an appId is set,
|
|
5
|
+
* all localStorage keys are namespaced to that app, allowing different
|
|
6
|
+
* apps to have independent sidebar preferences for the same user.
|
|
3
7
|
*/
|
|
8
|
+
import type { NavSection, SidebarPreferences } from '../types/layout.js';
|
|
4
9
|
declare class SidebarStore {
|
|
5
10
|
#private;
|
|
6
11
|
isOpen: boolean;
|
|
7
12
|
isMobile: boolean;
|
|
8
13
|
expandedSections: Set<string>;
|
|
9
14
|
expandedItems: Set<string>;
|
|
15
|
+
pinnedItems: Set<string>;
|
|
10
16
|
/**
|
|
11
17
|
* Whether the store has been initialized
|
|
12
18
|
*/
|
|
@@ -19,11 +25,22 @@ declare class SidebarStore {
|
|
|
19
25
|
* Whether expansion state was loaded from localStorage
|
|
20
26
|
*/
|
|
21
27
|
get hasPersistedExpansionState(): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Whether pinned state was loaded from localStorage
|
|
30
|
+
*/
|
|
31
|
+
get hasPersistedPinnedState(): boolean;
|
|
22
32
|
/**
|
|
23
33
|
* Initialize store with persisted state.
|
|
24
34
|
* Call this from a component's $effect to load saved state.
|
|
35
|
+
*
|
|
36
|
+
* @param appId - Optional app identifier for per-app preferences.
|
|
37
|
+
* When provided, localStorage keys are namespaced to this app.
|
|
38
|
+
*/
|
|
39
|
+
initialize(appId?: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Get the current app ID
|
|
25
42
|
*/
|
|
26
|
-
|
|
43
|
+
get appId(): string | undefined;
|
|
27
44
|
/**
|
|
28
45
|
* Toggle the sidebar open/closed state
|
|
29
46
|
*/
|
|
@@ -74,6 +91,45 @@ declare class SidebarStore {
|
|
|
74
91
|
* This bypasses the persisted state check but doesn't persist the changes
|
|
75
92
|
*/
|
|
76
93
|
forceExpandSections(sectionIds: string[]): void;
|
|
94
|
+
/**
|
|
95
|
+
* Set callback for when pinned items change (for external persistence)
|
|
96
|
+
*/
|
|
97
|
+
setOnPinnedChange(callback: ((pinnedIds: string[]) => void) | undefined): void;
|
|
98
|
+
/**
|
|
99
|
+
* Initialize pinned items from external source (server hydration)
|
|
100
|
+
* This takes precedence over localStorage
|
|
101
|
+
*/
|
|
102
|
+
initializePinnedItems(pinnedIds: string[]): void;
|
|
103
|
+
/**
|
|
104
|
+
* Pin a navigation item
|
|
105
|
+
*/
|
|
106
|
+
pinItem(itemId: string): void;
|
|
107
|
+
/**
|
|
108
|
+
* Unpin a navigation item
|
|
109
|
+
*/
|
|
110
|
+
unpinItem(itemId: string): void;
|
|
111
|
+
/**
|
|
112
|
+
* Toggle pin state of a navigation item
|
|
113
|
+
*/
|
|
114
|
+
togglePin(itemId: string): void;
|
|
115
|
+
/**
|
|
116
|
+
* Check if an item is pinned
|
|
117
|
+
*/
|
|
118
|
+
isPinned(itemId: string): boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Get all pinned item IDs
|
|
121
|
+
*/
|
|
122
|
+
getPinnedIds(): string[];
|
|
123
|
+
/**
|
|
124
|
+
* Compute which sections should be initially expanded based on user roles.
|
|
125
|
+
* Sections with expandForRoles that match user roles will be auto-expanded.
|
|
126
|
+
* Falls back to section.expanded or default behavior.
|
|
127
|
+
*/
|
|
128
|
+
computeInitialExpansions(sections: NavSection[], userRoles?: string[]): string[];
|
|
129
|
+
/**
|
|
130
|
+
* Get current sidebar preferences for external persistence
|
|
131
|
+
*/
|
|
132
|
+
getPreferences(): SidebarPreferences;
|
|
77
133
|
}
|
|
78
134
|
export declare const sidebarStore: SidebarStore;
|
|
79
135
|
export {};
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sidebar Store - Svelte 5 runes-based state management for sidebar
|
|
3
|
+
*
|
|
4
|
+
* Supports per-app preferences via appId parameter. When an appId is set,
|
|
5
|
+
* all localStorage keys are namespaced to that app, allowing different
|
|
6
|
+
* apps to have independent sidebar preferences for the same user.
|
|
3
7
|
*/
|
|
4
8
|
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
5
9
|
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
@@ -12,10 +16,8 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
|
|
|
12
16
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
13
17
|
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
14
18
|
};
|
|
15
|
-
var _SidebarStore_instances, _SidebarStore_initialized, _SidebarStore_loadedFromStorage, _SidebarStore_loadedExpansionFromStorage, _SidebarStore_persist, _SidebarStore_persistExpansion;
|
|
16
|
-
const
|
|
17
|
-
const SECTIONS_STORAGE_KEY = 'classic-theme-sidebar-sections';
|
|
18
|
-
const ITEMS_STORAGE_KEY = 'classic-theme-sidebar-items';
|
|
19
|
+
var _SidebarStore_instances, _SidebarStore_initialized, _SidebarStore_loadedFromStorage, _SidebarStore_loadedExpansionFromStorage, _SidebarStore_loadedPinnedFromStorage, _SidebarStore_onPinnedChange, _SidebarStore_appId, _SidebarStore_getStorageKey, _SidebarStore_persist, _SidebarStore_persistExpansion, _SidebarStore_persistPinned;
|
|
20
|
+
const STORAGE_PREFIX = 'classic-theme-sidebar';
|
|
19
21
|
class SidebarStore {
|
|
20
22
|
constructor() {
|
|
21
23
|
_SidebarStore_instances.add(this);
|
|
@@ -23,9 +25,13 @@ class SidebarStore {
|
|
|
23
25
|
this.isMobile = $state(false);
|
|
24
26
|
this.expandedSections = $state(new Set());
|
|
25
27
|
this.expandedItems = $state(new Set());
|
|
28
|
+
this.pinnedItems = $state(new Set());
|
|
26
29
|
_SidebarStore_initialized.set(this, false);
|
|
27
30
|
_SidebarStore_loadedFromStorage.set(this, false);
|
|
28
31
|
_SidebarStore_loadedExpansionFromStorage.set(this, false);
|
|
32
|
+
_SidebarStore_loadedPinnedFromStorage.set(this, false);
|
|
33
|
+
_SidebarStore_onPinnedChange.set(this, void 0);
|
|
34
|
+
_SidebarStore_appId.set(this, void 0);
|
|
29
35
|
}
|
|
30
36
|
/**
|
|
31
37
|
* Whether the store has been initialized
|
|
@@ -45,38 +51,66 @@ class SidebarStore {
|
|
|
45
51
|
get hasPersistedExpansionState() {
|
|
46
52
|
return __classPrivateFieldGet(this, _SidebarStore_loadedExpansionFromStorage, "f");
|
|
47
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether pinned state was loaded from localStorage
|
|
56
|
+
*/
|
|
57
|
+
get hasPersistedPinnedState() {
|
|
58
|
+
return __classPrivateFieldGet(this, _SidebarStore_loadedPinnedFromStorage, "f");
|
|
59
|
+
}
|
|
48
60
|
/**
|
|
49
61
|
* Initialize store with persisted state.
|
|
50
62
|
* Call this from a component's $effect to load saved state.
|
|
63
|
+
*
|
|
64
|
+
* @param appId - Optional app identifier for per-app preferences.
|
|
65
|
+
* When provided, localStorage keys are namespaced to this app.
|
|
51
66
|
*/
|
|
52
|
-
initialize() {
|
|
53
|
-
|
|
67
|
+
initialize(appId) {
|
|
68
|
+
// If appId changed, reset and reinitialize
|
|
69
|
+
if (__classPrivateFieldGet(this, _SidebarStore_initialized, "f") && __classPrivateFieldGet(this, _SidebarStore_appId, "f") === appId)
|
|
54
70
|
return;
|
|
71
|
+
if (typeof window === 'undefined')
|
|
72
|
+
return;
|
|
73
|
+
__classPrivateFieldSet(this, _SidebarStore_appId, appId, "f");
|
|
55
74
|
__classPrivateFieldSet(this, _SidebarStore_initialized, true, "f");
|
|
75
|
+
__classPrivateFieldSet(this, _SidebarStore_loadedFromStorage, false, "f");
|
|
76
|
+
__classPrivateFieldSet(this, _SidebarStore_loadedExpansionFromStorage, false, "f");
|
|
77
|
+
__classPrivateFieldSet(this, _SidebarStore_loadedPinnedFromStorage, false, "f");
|
|
56
78
|
try {
|
|
57
79
|
// Load sidebar open state
|
|
58
|
-
const stored = localStorage.getItem(
|
|
80
|
+
const stored = localStorage.getItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'open'));
|
|
59
81
|
if (stored !== null) {
|
|
60
82
|
this.isOpen = stored === 'true';
|
|
61
83
|
__classPrivateFieldSet(this, _SidebarStore_loadedFromStorage, true, "f");
|
|
62
84
|
}
|
|
63
85
|
// Load expanded sections
|
|
64
|
-
const sections = localStorage.getItem(
|
|
86
|
+
const sections = localStorage.getItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'sections'));
|
|
65
87
|
if (sections) {
|
|
66
88
|
this.expandedSections = new Set(JSON.parse(sections));
|
|
67
89
|
__classPrivateFieldSet(this, _SidebarStore_loadedExpansionFromStorage, true, "f");
|
|
68
90
|
}
|
|
69
91
|
// Load expanded items
|
|
70
|
-
const items = localStorage.getItem(
|
|
92
|
+
const items = localStorage.getItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'items'));
|
|
71
93
|
if (items) {
|
|
72
94
|
this.expandedItems = new Set(JSON.parse(items));
|
|
73
95
|
__classPrivateFieldSet(this, _SidebarStore_loadedExpansionFromStorage, true, "f");
|
|
74
96
|
}
|
|
97
|
+
// Load pinned items
|
|
98
|
+
const pinned = localStorage.getItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'pinned'));
|
|
99
|
+
if (pinned) {
|
|
100
|
+
this.pinnedItems = new Set(JSON.parse(pinned));
|
|
101
|
+
__classPrivateFieldSet(this, _SidebarStore_loadedPinnedFromStorage, true, "f");
|
|
102
|
+
}
|
|
75
103
|
}
|
|
76
104
|
catch {
|
|
77
105
|
// localStorage not available
|
|
78
106
|
}
|
|
79
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Get the current app ID
|
|
110
|
+
*/
|
|
111
|
+
get appId() {
|
|
112
|
+
return __classPrivateFieldGet(this, _SidebarStore_appId, "f");
|
|
113
|
+
}
|
|
80
114
|
/**
|
|
81
115
|
* Toggle the sidebar open/closed state
|
|
82
116
|
*/
|
|
@@ -184,12 +218,107 @@ class SidebarStore {
|
|
|
184
218
|
this.expandedSections = newSet;
|
|
185
219
|
// Don't persist - this is temporary for search
|
|
186
220
|
}
|
|
221
|
+
// ============================================
|
|
222
|
+
// Pinning Methods
|
|
223
|
+
// ============================================
|
|
224
|
+
/**
|
|
225
|
+
* Set callback for when pinned items change (for external persistence)
|
|
226
|
+
*/
|
|
227
|
+
setOnPinnedChange(callback) {
|
|
228
|
+
__classPrivateFieldSet(this, _SidebarStore_onPinnedChange, callback, "f");
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Initialize pinned items from external source (server hydration)
|
|
232
|
+
* This takes precedence over localStorage
|
|
233
|
+
*/
|
|
234
|
+
initializePinnedItems(pinnedIds) {
|
|
235
|
+
this.pinnedItems = new Set(pinnedIds);
|
|
236
|
+
__classPrivateFieldSet(this, _SidebarStore_loadedPinnedFromStorage, true, "f"); // Prevent localStorage override
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Pin a navigation item
|
|
240
|
+
*/
|
|
241
|
+
pinItem(itemId) {
|
|
242
|
+
const newSet = new Set(this.pinnedItems);
|
|
243
|
+
newSet.add(itemId);
|
|
244
|
+
this.pinnedItems = newSet;
|
|
245
|
+
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persistPinned).call(this);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Unpin a navigation item
|
|
249
|
+
*/
|
|
250
|
+
unpinItem(itemId) {
|
|
251
|
+
const newSet = new Set(this.pinnedItems);
|
|
252
|
+
newSet.delete(itemId);
|
|
253
|
+
this.pinnedItems = newSet;
|
|
254
|
+
__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_persistPinned).call(this);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Toggle pin state of a navigation item
|
|
258
|
+
*/
|
|
259
|
+
togglePin(itemId) {
|
|
260
|
+
if (this.pinnedItems.has(itemId)) {
|
|
261
|
+
this.unpinItem(itemId);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
this.pinItem(itemId);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Check if an item is pinned
|
|
269
|
+
*/
|
|
270
|
+
isPinned(itemId) {
|
|
271
|
+
return this.pinnedItems.has(itemId);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Get all pinned item IDs
|
|
275
|
+
*/
|
|
276
|
+
getPinnedIds() {
|
|
277
|
+
return [...this.pinnedItems];
|
|
278
|
+
}
|
|
279
|
+
// ============================================
|
|
280
|
+
// Role-based Section Expansion
|
|
281
|
+
// ============================================
|
|
282
|
+
/**
|
|
283
|
+
* Compute which sections should be initially expanded based on user roles.
|
|
284
|
+
* Sections with expandForRoles that match user roles will be auto-expanded.
|
|
285
|
+
* Falls back to section.expanded or default behavior.
|
|
286
|
+
*/
|
|
287
|
+
computeInitialExpansions(sections, userRoles) {
|
|
288
|
+
return sections
|
|
289
|
+
.filter((section) => {
|
|
290
|
+
// Check role-based expansion first
|
|
291
|
+
if (section.expandForRoles?.length) {
|
|
292
|
+
return section.expandForRoles.some((role) => userRoles?.includes(role));
|
|
293
|
+
}
|
|
294
|
+
// Fall back to default behavior: expand unless explicitly collapsed
|
|
295
|
+
return !section.collapsible || section.expanded !== false;
|
|
296
|
+
})
|
|
297
|
+
.map((section) => section.id);
|
|
298
|
+
}
|
|
299
|
+
// ============================================
|
|
300
|
+
// Preferences Export
|
|
301
|
+
// ============================================
|
|
302
|
+
/**
|
|
303
|
+
* Get current sidebar preferences for external persistence
|
|
304
|
+
*/
|
|
305
|
+
getPreferences() {
|
|
306
|
+
return {
|
|
307
|
+
pinnedItems: [...this.pinnedItems],
|
|
308
|
+
expandedSections: [...this.expandedSections],
|
|
309
|
+
expandedItems: [...this.expandedItems],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
187
312
|
}
|
|
188
|
-
_SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new WeakMap(), _SidebarStore_loadedExpansionFromStorage = new WeakMap(), _SidebarStore_instances = new WeakSet(),
|
|
313
|
+
_SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new WeakMap(), _SidebarStore_loadedExpansionFromStorage = new WeakMap(), _SidebarStore_loadedPinnedFromStorage = new WeakMap(), _SidebarStore_onPinnedChange = new WeakMap(), _SidebarStore_appId = new WeakMap(), _SidebarStore_instances = new WeakSet(), _SidebarStore_getStorageKey = function _SidebarStore_getStorageKey(suffix) {
|
|
314
|
+
return __classPrivateFieldGet(this, _SidebarStore_appId, "f")
|
|
315
|
+
? `${STORAGE_PREFIX}-${__classPrivateFieldGet(this, _SidebarStore_appId, "f")}-${suffix}`
|
|
316
|
+
: `${STORAGE_PREFIX}-${suffix}`;
|
|
317
|
+
}, _SidebarStore_persist = function _SidebarStore_persist() {
|
|
189
318
|
if (typeof window === 'undefined')
|
|
190
319
|
return;
|
|
191
320
|
try {
|
|
192
|
-
localStorage.setItem(
|
|
321
|
+
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'open'), String(this.isOpen));
|
|
193
322
|
}
|
|
194
323
|
catch {
|
|
195
324
|
// localStorage not available
|
|
@@ -198,11 +327,22 @@ _SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new
|
|
|
198
327
|
if (typeof window === 'undefined')
|
|
199
328
|
return;
|
|
200
329
|
try {
|
|
201
|
-
localStorage.setItem(
|
|
202
|
-
localStorage.setItem(
|
|
330
|
+
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'sections'), JSON.stringify([...this.expandedSections]));
|
|
331
|
+
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'items'), JSON.stringify([...this.expandedItems]));
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// localStorage not available
|
|
335
|
+
}
|
|
336
|
+
}, _SidebarStore_persistPinned = function _SidebarStore_persistPinned() {
|
|
337
|
+
if (typeof window === 'undefined')
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'pinned'), JSON.stringify([...this.pinnedItems]));
|
|
203
341
|
}
|
|
204
342
|
catch {
|
|
205
343
|
// localStorage not available
|
|
206
344
|
}
|
|
345
|
+
// Notify external callback for server persistence
|
|
346
|
+
__classPrivateFieldGet(this, _SidebarStore_onPinnedChange, "f")?.call(this, [...this.pinnedItems]);
|
|
207
347
|
};
|
|
208
348
|
export const sidebarStore = new SidebarStore();
|
|
@@ -92,6 +92,8 @@ export interface NavItem {
|
|
|
92
92
|
disabled?: boolean;
|
|
93
93
|
/** Whether the item is in a loading state */
|
|
94
94
|
loading?: boolean;
|
|
95
|
+
/** Whether this item can be pinned by users */
|
|
96
|
+
pinnable?: boolean;
|
|
95
97
|
}
|
|
96
98
|
/**
|
|
97
99
|
* Navigation section for grouping nav items
|
|
@@ -107,6 +109,20 @@ export interface NavSection {
|
|
|
107
109
|
collapsible?: boolean;
|
|
108
110
|
/** Is section currently expanded (for collapsible sections) */
|
|
109
111
|
expanded?: boolean;
|
|
112
|
+
/** Auto-expand this section for users with these roles */
|
|
113
|
+
expandForRoles?: string[];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Sidebar preferences for external persistence
|
|
117
|
+
* Used to save/restore user customizations like pinned items
|
|
118
|
+
*/
|
|
119
|
+
export interface SidebarPreferences {
|
|
120
|
+
/** IDs of pinned navigation items */
|
|
121
|
+
pinnedItems: string[];
|
|
122
|
+
/** IDs of expanded sections */
|
|
123
|
+
expandedSections: string[];
|
|
124
|
+
/** IDs of expanded items (items with children that are expanded) */
|
|
125
|
+
expandedItems: string[];
|
|
110
126
|
}
|
|
111
127
|
/**
|
|
112
128
|
* User data for layout components
|
|
@@ -216,8 +232,22 @@ export interface DashboardLayoutProps {
|
|
|
216
232
|
expandedWidth?: number;
|
|
217
233
|
/** Sidebar width when collapsed in pixels (default: 64) */
|
|
218
234
|
collapsedWidth?: number;
|
|
235
|
+
/** Enable pinning feature for navigation items */
|
|
236
|
+
enablePinning?: boolean;
|
|
237
|
+
/** Title for the pinned items section (default: "Pinned") */
|
|
238
|
+
pinnedSectionTitle?: string;
|
|
239
|
+
/** Initial pinned item IDs for server hydration */
|
|
240
|
+
initialPinnedItems?: string[];
|
|
241
|
+
/** Callback when pinned items change (for external persistence) */
|
|
242
|
+
onPinnedChange?: (pinnedIds: string[]) => void;
|
|
243
|
+
/** Custom pin icon renderer */
|
|
244
|
+
pinIcon?: Snippet<[{
|
|
245
|
+
isPinned: boolean;
|
|
246
|
+
}]>;
|
|
219
247
|
/** Header search configuration */
|
|
220
248
|
headerSearch?: HeaderSearchConfig;
|
|
249
|
+
/** App identifier for per-app sidebar preferences storage */
|
|
250
|
+
appId?: string;
|
|
221
251
|
/** Main content */
|
|
222
252
|
children: Snippet;
|
|
223
253
|
}
|
|
@@ -292,6 +322,20 @@ export interface SidebarProps {
|
|
|
292
322
|
searchable?: boolean;
|
|
293
323
|
/** Placeholder text for search input */
|
|
294
324
|
searchPlaceholder?: string;
|
|
325
|
+
/** Enable pinning feature for navigation items */
|
|
326
|
+
enablePinning?: boolean;
|
|
327
|
+
/** Title for the pinned items section (default: "Pinned") */
|
|
328
|
+
pinnedSectionTitle?: string;
|
|
329
|
+
/** Initial pinned item IDs for server hydration */
|
|
330
|
+
initialPinnedItems?: string[];
|
|
331
|
+
/** Callback when pinned items change (for external persistence) */
|
|
332
|
+
onPinnedChange?: (pinnedIds: string[]) => void;
|
|
333
|
+
/** Custom pin icon renderer */
|
|
334
|
+
pinIcon?: Snippet<[{
|
|
335
|
+
isPinned: boolean;
|
|
336
|
+
}]>;
|
|
337
|
+
/** App identifier for per-app sidebar preferences storage */
|
|
338
|
+
appId?: string;
|
|
295
339
|
/** Additional classes */
|
|
296
340
|
class?: string;
|
|
297
341
|
}
|