@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.
@@ -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
- // Set default expansions if no persisted state
164
- const defaultExpanded: string[] = [];
165
- roleFilteredNavigation.forEach((section) => {
166
- // Expand by default unless explicitly set to collapsed
167
- if (!section.collapsible || section.expanded !== false) {
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
- {#if item.href}
304
- <!-- Item with href - render as link -->
305
- <a
306
- href={item.disabled ? undefined : item.href}
307
- class={cn(
308
- 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors overflow-hidden relative',
309
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
310
- getItemStateClasses(item.active, item.disabled),
311
- collapsed && !isMobile && 'justify-center px-2'
312
- )}
313
- target={item.external ? '_blank' : undefined}
314
- rel={item.external ? 'noopener noreferrer' : undefined}
315
- aria-current={item.active ? 'page' : undefined}
316
- aria-disabled={item.disabled || undefined}
317
- tabindex={item.disabled ? -1 : undefined}
318
- title={collapsed && !isMobile
319
- ? item.external
320
- ? `${item.name} (opens in new tab)`
321
- : item.name
322
- : undefined}
323
- onclick={handleClick}
324
- onmouseenter={handleMouseEnter}
325
- onmouseleave={onMouseLeave}
326
- >
327
- {@render navIcon(item)}
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
- {#if !collapsed || isMobile}
330
- {@render highlightedName(item.name, itemHighlight)}
331
- {@render navBadge(item.badge)}
332
- {#if item.external}{@render externalIndicator()}{/if}
333
- {/if}
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
- <!-- Visual indicator for collapsed items with children -->
336
- {#if collapsed && !isMobile && hasChildren}
337
- <span class="absolute bottom-0.5 right-0.5">
338
- <svg class="h-2 w-2 opacity-50" fill="currentColor" viewBox="0 0 8 8">
339
- <path d="M0 0 L8 4 L0 8 Z" />
340
- </svg>
341
- </span>
342
- {/if}
343
- </a>
344
- {:else}
345
- <!-- Item without href (parent with children only, or action button) - render as button -->
346
- <button
347
- type="button"
348
- class={cn(
349
- 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors overflow-hidden w-full text-left relative',
350
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
351
- getItemStateClasses(item.active, item.disabled),
352
- collapsed && !isMobile && 'justify-center px-2'
353
- )}
354
- title={collapsed && !isMobile ? item.name : undefined}
355
- aria-haspopup={hasChildren ? 'menu' : undefined}
356
- aria-disabled={item.disabled || undefined}
357
- disabled={item.disabled}
358
- onclick={handleClick}
359
- onmouseenter={handleMouseEnter}
360
- onmouseleave={onMouseLeave}
361
- >
362
- {@render navIcon(item)}
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
- {#if !collapsed || isMobile}
365
- {@render highlightedName(item.name, itemHighlight)}
366
- {@render navBadge(item.badge)}
367
- {/if}
477
+ {#if !collapsed || isMobile}
478
+ {@render highlightedName(item.name, itemHighlight)}
479
+ {@render navBadge(item.badge)}
480
+ {/if}
368
481
 
369
- <!-- Visual indicator for collapsed items with children -->
370
- {#if collapsed && !isMobile && hasChildren}
371
- <span class="absolute bottom-0.5 right-0.5">
372
- <svg class="h-2 w-2 opacity-50" fill="currentColor" viewBox="0 0 8 8">
373
- <path d="M0 0 L8 4 L0 8 Z" />
374
- </svg>
375
- </span>
376
- {/if}
377
- </button>
378
- {/if}
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) */
@@ -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
- initialize(): void;
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 STORAGE_KEY = 'classic-theme-sidebar-open';
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
- if (__classPrivateFieldGet(this, _SidebarStore_initialized, "f") || typeof window === 'undefined')
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(STORAGE_KEY);
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(SECTIONS_STORAGE_KEY);
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(ITEMS_STORAGE_KEY);
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(), _SidebarStore_persist = function _SidebarStore_persist() {
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(STORAGE_KEY, String(this.isOpen));
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(SECTIONS_STORAGE_KEY, JSON.stringify([...this.expandedSections]));
202
- localStorage.setItem(ITEMS_STORAGE_KEY, JSON.stringify([...this.expandedItems]));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",