@classic-homes/theme-svelte 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/__tests__/alert-dialog-dismiss.test.js +54 -0
  2. package/dist/__tests__/dropdown-trigger.test.js +39 -0
  3. package/dist/__tests__/sidebar-docs-parity.test.js +134 -0
  4. package/dist/__tests__/sidebar-items.test.js +59 -0
  5. package/dist/__tests__/sidebar-mobile.test.js +13 -0
  6. package/dist/lib/components/AlertDialog.svelte +19 -2
  7. package/dist/lib/components/Button.svelte +1 -31
  8. package/dist/lib/components/Button.svelte.d.ts +9 -6
  9. package/dist/lib/components/DropdownMenu.svelte +35 -1
  10. package/dist/lib/components/DropdownMenu.svelte.d.ts +13 -0
  11. package/dist/lib/components/button-variants.d.ts +119 -0
  12. package/dist/lib/components/button-variants.js +29 -0
  13. package/dist/lib/components/layout/DashboardLayout.svelte +44 -12
  14. package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +4 -0
  15. package/dist/lib/components/layout/Sidebar.svelte +38 -25
  16. package/dist/lib/components/layout/Sidebar.svelte.d.ts +5 -0
  17. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +54 -32
  18. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +68 -37
  19. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +5 -0
  20. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +21 -4
  21. package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +4 -0
  22. package/dist/lib/index.d.ts +1 -0
  23. package/dist/lib/index.js +1 -0
  24. package/dist/lib/stores/sidebar.svelte.d.ts +10 -4
  25. package/dist/lib/stores/sidebar.svelte.js +25 -4
  26. package/package.json +1 -1
@@ -71,6 +71,10 @@
71
71
  userMenu?: Snippet<[User]>;
72
72
  /** Sidebar footer content */
73
73
  sidebarFooter?: Snippet;
74
+ /** Page footer below the main content, e.g. theme `Footer` with link columns */
75
+ footer?: Snippet;
76
+ /** Accordion sidebar: opening a section or item closes the others at its level */
77
+ sidebarAutoCollapse?: boolean;
74
78
  /** Sidebar width when expanded in pixels (default: 256) */
75
79
  expandedWidth?: number;
76
80
  /** Sidebar width when collapsed in pixels (default: 64) */
@@ -122,6 +126,8 @@
122
126
  headerEnd,
123
127
  userMenu,
124
128
  sidebarFooter,
129
+ footer,
130
+ sidebarAutoCollapse = false,
125
131
  expandedWidth = 256,
126
132
  collapsedWidth = 64,
127
133
  searchable = false,
@@ -159,11 +165,10 @@
159
165
  : navigation
160
166
  );
161
167
 
162
- // Reactive media query for mobile detection. Initialized synchronously so
163
- // the first client paint doesn't flash the desktop layout (256px content
164
- // margin + full-width sidebar) before an effect corrects it. Deliberately
165
- // reads the initial `mobileBreakpoint` — later changes are handled by the
166
- // matchMedia effect below.
168
+ // Reactive media query for mobile detection. Initialized synchronously so a
169
+ // client-only render paints the right layout at once. Deliberately reads the
170
+ // initial `mobileBreakpoint` — later changes are handled by the matchMedia
171
+ // effect below.
167
172
  // svelte-ignore state_referenced_locally
168
173
  const initialIsMobile =
169
174
  typeof window !== 'undefined' &&
@@ -173,12 +178,23 @@
173
178
  sidebarStore.setMobile(initialIsMobile);
174
179
  }
175
180
 
176
- // Suppress the margin transition until after the first paint so any
177
- // hydration correction snaps instead of visibly sliding.
181
+ // Until mounted the layout is responsive in CSS alone: the server cannot know
182
+ // the viewport, so a JS-sized layout would render as desktop and visibly snap
183
+ // to mobile on hydration. Server HTML and the hydrating render therefore share
184
+ // breakpoint classes; once mounted, JS state (collapse, drawer) takes over.
178
185
  let mounted = $state(false);
186
+ // One frame later still: lets the hand-over to JS sizing (e.g. a saved collapsed
187
+ // sidebar) snap instead of sliding.
188
+ let animate = $state(false);
179
189
 
180
190
  $effect(() => {
181
191
  mounted = true;
192
+ if (typeof requestAnimationFrame !== 'function') {
193
+ animate = true;
194
+ return;
195
+ }
196
+ const frame = requestAnimationFrame(() => (animate = true));
197
+ return () => cancelAnimationFrame(frame);
182
198
  });
183
199
 
184
200
  $effect(() => {
@@ -246,6 +262,14 @@
246
262
  isMobile ? 0 : sidebarStore.isOpen ? expandedWidth : collapsedWidth
247
263
  );
248
264
 
265
+ // Pre-mount responsive classes, per breakpoint (literal strings, for Tailwind)
266
+ const ssrClasses: Record<Breakpoint, { content: string; sidebar: string }> = {
267
+ sm: { content: 'sm:ml-[var(--dl-sidebar-width)]', sidebar: 'max-sm:-translate-x-full' },
268
+ md: { content: 'md:ml-[var(--dl-sidebar-width)]', sidebar: 'max-md:-translate-x-full' },
269
+ lg: { content: 'lg:ml-[var(--dl-sidebar-width)]', sidebar: 'max-lg:-translate-x-full' },
270
+ };
271
+ const ssr = $derived(ssrClasses[mobileBreakpoint]);
272
+
249
273
  const contentPaddingClass = $derived(
250
274
  contentPadding === 'none' ? '' : contentPadding === 'compact' ? 'p-2 lg:p-4' : 'p-4 lg:p-6'
251
275
  );
@@ -269,6 +293,7 @@
269
293
  {quickLinksDisplay}
270
294
  quickLinkIcon={quickLinkIcon ?? (icon as Snippet<[QuickLink]> | undefined)}
271
295
  footer={sidebarFooter}
296
+ autoCollapse={sidebarAutoCollapse}
272
297
  {expandedWidth}
273
298
  {collapsedWidth}
274
299
  {searchable}
@@ -279,21 +304,25 @@
279
304
  {onPinnedChange}
280
305
  {pinIcon}
281
306
  {appId}
307
+ class={mounted ? undefined : ssr.sidebar}
282
308
  />
283
309
 
284
310
  <!-- Main Content Area -->
285
311
  <div
286
- class="flex flex-1 flex-col {mounted
312
+ class="flex flex-1 flex-col {mounted ? '' : ssr.content} {animate
287
313
  ? 'transition-[margin] duration-300 motion-reduce:transition-none'
288
314
  : ''}"
289
- style="margin-left: {sidebarWidth}px;"
315
+ style={mounted
316
+ ? `margin-left: ${sidebarWidth}px;`
317
+ : `--dl-sidebar-width: ${sidebarCollapsed ? collapsedWidth : expandedWidth}px;`}
290
318
  >
291
319
  {#if showHeader}
292
320
  <Header
293
- showMenuButton={isMobile}
294
- menuOpen={sidebarStore.isOpen}
321
+ {mobileBreakpoint}
322
+ showMenuButton={isMobile || !mounted}
323
+ menuOpen={mounted && isMobile && sidebarStore.isOpen}
295
324
  onMenuClick={() => sidebarStore.toggle()}
296
- showCollapseButton={!isMobile}
325
+ showCollapseButton={!isMobile || !mounted}
297
326
  sidebarCollapsed={!sidebarStore.isOpen}
298
327
  onCollapseClick={() => sidebarStore.toggle()}
299
328
  title={pageTitle}
@@ -334,5 +363,8 @@
334
363
  >
335
364
  {@render children()}
336
365
  </main>
366
+ {#if footer}
367
+ {@render footer()}
368
+ {/if}
337
369
  </div>
338
370
  </AppShell>
@@ -49,6 +49,10 @@ interface Props {
49
49
  userMenu?: Snippet<[User]>;
50
50
  /** Sidebar footer content */
51
51
  sidebarFooter?: Snippet;
52
+ /** Page footer below the main content, e.g. theme `Footer` with link columns */
53
+ footer?: Snippet;
54
+ /** Accordion sidebar: opening a section or item closes the others at its level */
55
+ sidebarAutoCollapse?: boolean;
52
56
  /** Sidebar width when expanded in pixels (default: 256) */
53
57
  expandedWidth?: number;
54
58
  /** Sidebar width when collapsed in pixels (default: 64) */
@@ -69,6 +69,11 @@
69
69
  collapsedWidth?: number;
70
70
  /** Enable search/filter input for navigation */
71
71
  searchable?: boolean;
72
+ /**
73
+ * Accordion navigation: opening a collapsible section or an item closes the others at
74
+ * its level (Docusaurus `autoCollapseCategories`). Ignored while searching.
75
+ */
76
+ autoCollapse?: boolean;
72
77
  /** Placeholder text for search input */
73
78
  searchPlaceholder?: string;
74
79
  /** Enable pinning feature for navigation items */
@@ -109,6 +114,7 @@
109
114
  expandedWidth = 256,
110
115
  collapsedWidth = 64,
111
116
  searchable = false,
117
+ autoCollapse = false,
112
118
  searchPlaceholder = 'Search...',
113
119
  enablePinning = false,
114
120
  pinnedSectionTitle = 'Pinned',
@@ -154,14 +160,17 @@
154
160
 
155
161
  // Helper to find a nav item by ID (including nested children)
156
162
  function findNavItemById(sections: NavSection[], itemId: string): NavItem | undefined {
157
- for (const section of sections) {
158
- for (const item of section.items) {
163
+ const find = (items: NavItem[]): NavItem | undefined => {
164
+ for (const item of items) {
159
165
  if (item.id === itemId) return item;
160
- if (item.children) {
161
- const found = item.children.find((child) => child.id === itemId);
162
- if (found) return found;
163
- }
166
+ const found = item.children && find(item.children);
167
+ if (found) return found;
164
168
  }
169
+ return undefined;
170
+ };
171
+ for (const section of sections) {
172
+ const found = find(section.items);
173
+ if (found) return found;
165
174
  }
166
175
  return undefined;
167
176
  }
@@ -199,23 +208,28 @@
199
208
  // Search state
200
209
  let searchQuery = $state('');
201
210
 
211
+ // Sections an expanding section closes under autoCollapse
212
+ const collapsibleSectionIds = $derived(
213
+ autoCollapse ? roleFilteredNavigation.filter((s) => s.collapsible).map((s) => s.id) : []
214
+ );
215
+
202
216
  // Filter items by search query
217
+ // At any depth: an item stays when it or a descendant matches. A matching item keeps all
218
+ // its children; otherwise only the branches leading to matches remain.
203
219
  function filterItemsBySearch(items: NavItem[], query: string): NavItem[] {
204
- return items
205
- .filter((item) => {
206
- const matches = item.name.toLowerCase().includes(query);
207
- const childMatches = item.children?.some((child) =>
208
- child.name.toLowerCase().includes(query)
209
- );
210
- return matches || childMatches;
211
- })
212
- .map((item) => ({
213
- ...item,
214
- // Keep all children if parent matches, otherwise filter children
215
- children: item.name.toLowerCase().includes(query)
216
- ? item.children
217
- : item.children?.filter((child) => child.name.toLowerCase().includes(query)),
218
- }));
220
+ return items.flatMap((item) => {
221
+ if (item.name.toLowerCase().includes(query)) return [item];
222
+ const children = item.children ? filterItemsBySearch(item.children, query) : [];
223
+ return children.length > 0 ? [{ ...item, children }] : [];
224
+ });
225
+ }
226
+
227
+ function hasMatchingDescendant(item: NavItem, query: string): boolean {
228
+ return (
229
+ item.children?.some(
230
+ (child) => child.name.toLowerCase().includes(query) || hasMatchingDescendant(child, query)
231
+ ) ?? false
232
+ );
219
233
  }
220
234
 
221
235
  // Combined filtered navigation (roles + search)
@@ -270,10 +284,7 @@
270
284
  const collectMatchingParents = (items: NavItem[]) => {
271
285
  items.forEach((item) => {
272
286
  if (item.children) {
273
- const hasMatchingChild = item.children.some((child) =>
274
- child.name.toLowerCase().includes(query)
275
- );
276
- if (hasMatchingChild) {
287
+ if (hasMatchingDescendant(item, query)) {
277
288
  itemsToExpand.push(item.id);
278
289
  }
279
290
  collectMatchingParents(item.children);
@@ -566,6 +577,8 @@
566
577
  onNavigate={handleNavigate}
567
578
  onItemMouseEnter={handleItemMouseEnter}
568
579
  onItemMouseLeave={handleItemMouseLeave}
580
+ autoCollapse={autoCollapse && !searchQuery.trim()}
581
+ siblingSectionIds={collapsibleSectionIds}
569
582
  />
570
583
  {:else}
571
584
  {#if searchQuery.trim()}
@@ -59,6 +59,11 @@ interface Props {
59
59
  collapsedWidth?: number;
60
60
  /** Enable search/filter input for navigation */
61
61
  searchable?: boolean;
62
+ /**
63
+ * Accordion navigation: opening a collapsible section or an item closes the others at
64
+ * its level (Docusaurus `autoCollapseCategories`). Ignored while searching.
65
+ */
66
+ autoCollapse?: boolean;
62
67
  /** Placeholder text for search input */
63
68
  searchPlaceholder?: string;
64
69
  /** Enable pinning feature for navigation items */
@@ -95,6 +95,59 @@
95
95
  }
96
96
  </script>
97
97
 
98
+ {#snippet childLinks(children: NavItem[], depth: number)}
99
+ {#each children as child (child.id)}
100
+ {#if child.href}
101
+ <a
102
+ href={child.disabled ? undefined : child.href}
103
+ class={cn(
104
+ 'flex items-center gap-2 rounded-md px-3 py-2 text-sm',
105
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
106
+ isLight
107
+ ? 'text-foreground hover:bg-accent hover:text-accent-foreground'
108
+ : 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
109
+ child.active &&
110
+ (isLight
111
+ ? 'bg-primary/10 text-primary'
112
+ : 'bg-sidebar-primary text-sidebar-primary-foreground'),
113
+ child.disabled && 'opacity-50 pointer-events-none cursor-not-allowed'
114
+ )}
115
+ style="margin-left: {0.5 + depth * 0.75}rem"
116
+ target={child.external ? '_blank' : undefined}
117
+ rel={child.external ? 'noopener noreferrer' : undefined}
118
+ aria-disabled={child.disabled || undefined}
119
+ tabindex={child.disabled ? -1 : undefined}
120
+ onclick={(e) => {
121
+ if (child.disabled) {
122
+ e.preventDefault();
123
+ return;
124
+ }
125
+ onNavigate?.(child);
126
+ }}
127
+ role="menuitem"
128
+ >
129
+ {child.name}
130
+ {@render navBadge(child.badge)}
131
+ </a>
132
+ {:else if child.children?.length}
133
+ <!-- A nested group without its own page: a label over its entries -->
134
+ <div
135
+ class={cn(
136
+ 'px-3 pt-2 pb-1 text-xs font-semibold',
137
+ isLight ? 'text-muted-foreground' : 'text-sidebar-foreground/60'
138
+ )}
139
+ style="margin-left: {0.5 + depth * 0.75}rem"
140
+ role="presentation"
141
+ >
142
+ {child.name}
143
+ </div>
144
+ {/if}
145
+ {#if child.children?.length}
146
+ {@render childLinks(child.children, depth + 1)}
147
+ {/if}
148
+ {/each}
149
+ {/snippet}
150
+
98
151
  {#snippet navBadge(badge: string | number | undefined)}
99
152
  {#if badge !== undefined}
100
153
  <span
@@ -169,38 +222,7 @@
169
222
  <div
170
223
  class={cn('border-t mt-1 pt-1', isLight ? 'border-border' : 'border-sidebar-foreground/20')}
171
224
  >
172
- {#each item.children as child}
173
- <a
174
- href={child.disabled ? undefined : child.href}
175
- class={cn(
176
- 'flex items-center gap-2 rounded-md px-3 py-2 text-sm ml-2',
177
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
178
- isLight
179
- ? 'text-foreground hover:bg-accent hover:text-accent-foreground'
180
- : 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
181
- child.active &&
182
- (isLight
183
- ? 'bg-primary/10 text-primary'
184
- : 'bg-sidebar-primary text-sidebar-primary-foreground'),
185
- child.disabled && 'opacity-50 pointer-events-none cursor-not-allowed'
186
- )}
187
- target={child.external ? '_blank' : undefined}
188
- rel={child.external ? 'noopener noreferrer' : undefined}
189
- aria-disabled={child.disabled || undefined}
190
- tabindex={child.disabled ? -1 : undefined}
191
- onclick={(e) => {
192
- if (child.disabled) {
193
- e.preventDefault();
194
- return;
195
- }
196
- onNavigate?.(child);
197
- }}
198
- role="menuitem"
199
- >
200
- {child.name}
201
- {@render navBadge(child.badge)}
202
- </a>
203
- {/each}
225
+ {@render childLinks(item.children, 0)}
204
226
  </div>
205
227
  {/if}
206
228
  </div>
@@ -3,7 +3,7 @@
3
3
  * SidebarNavItem - Individual navigation item component
4
4
  *
5
5
  * Handles:
6
- * - Items with children (expandable submenu)
6
+ * - Items with children (expandable submenu), nested to any depth
7
7
  * - Items without children (simple links/buttons)
8
8
  * - Collapsed mode (icons only with flyout)
9
9
  * - Disabled and loading states
@@ -13,6 +13,8 @@
13
13
  import type { NavItem } from '../../../types/layout.js';
14
14
  import { cn } from '../../../utils.js';
15
15
  import { sidebarStore } from '../../../stores/sidebar.svelte.js';
16
+ import Icon, { type IconName } from '../../Icon.svelte';
17
+ import SidebarNavItem from './SidebarNavItem.svelte';
16
18
 
17
19
  interface Props {
18
20
  /** The navigation item to render */
@@ -39,6 +41,10 @@
39
41
  onMouseEnter?: (item: NavItem, event: MouseEvent) => void;
40
42
  /** Callback when mouse leaves item (for flyout) */
41
43
  onMouseLeave?: () => void;
44
+ /** Expanding this item closes `siblingIds`, and its children behave the same way */
45
+ autoCollapse?: boolean;
46
+ /** IDs of expandable items alongside this one */
47
+ siblingIds?: string[];
42
48
  }
43
49
 
44
50
  let {
@@ -54,6 +60,8 @@
54
60
  onNavigate,
55
61
  onMouseEnter,
56
62
  onMouseLeave,
63
+ autoCollapse = false,
64
+ siblingIds = [],
57
65
  }: Props = $props();
58
66
 
59
67
  // Highlight matching text in item names
@@ -79,9 +87,14 @@
79
87
  const isExpanded = $derived(sidebarStore.isItemExpanded(item.id));
80
88
 
81
89
  function toggleItem() {
82
- sidebarStore.toggleItem(item.id);
90
+ sidebarStore.toggleItem(item.id, autoCollapse ? siblingIds : []);
83
91
  }
84
92
 
93
+ // Children that can expand: siblings to each other under autoCollapse
94
+ const expandableChildIds = $derived(
95
+ autoCollapse ? (item.children ?? []).filter((c) => c.children?.length).map((c) => c.id) : []
96
+ );
97
+
85
98
  function getItemStateClasses(active?: boolean, disabled?: boolean): string {
86
99
  return cn(
87
100
  isLight &&
@@ -142,10 +155,10 @@
142
155
  {@render icon(navItem)}
143
156
  </span>
144
157
  {:else if navItem.icon}
158
+ <!-- No icon renderer given: draw the named theme icon. Items without an
159
+ icon render nothing, so they take no gutter. -->
145
160
  <span class="h-5 w-5 shrink-0 flex items-center justify-center">
146
- <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
147
- <circle cx="12" cy="12" r="10" stroke-width="2" />
148
- </svg>
161
+ <Icon name={navItem.icon as IconName} size={20} />
149
162
  </span>
150
163
  {/if}
151
164
  {/snippet}
@@ -264,7 +277,7 @@
264
277
  <a
265
278
  href={item.disabled ? undefined : item.href}
266
279
  class={cn(
267
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium',
280
+ 'flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-sm font-medium',
268
281
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
269
282
  'group-hover:underline underline-offset-2',
270
283
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
@@ -292,7 +305,7 @@
292
305
  <button
293
306
  type="button"
294
307
  class={cn(
295
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left',
308
+ 'flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left',
296
309
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
297
310
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
298
311
  isLight && item.active && 'text-primary',
@@ -333,7 +346,8 @@
333
346
  >
334
347
  <svg
335
348
  class={cn(
336
- 'h-4 w-4 transition-transform duration-200',
349
+ 'h-4 w-4',
350
+ sidebarStore.animate && 'transition-transform duration-200',
337
351
  isExpanded ? 'rotate-0' : '-rotate-90'
338
352
  )}
339
353
  fill="none"
@@ -358,34 +372,51 @@
358
372
  </div>
359
373
 
360
374
  {#if isExpanded}
361
- <ul class="mt-1 space-y-1 pl-4 overflow-hidden" transition:slide={{ duration: 200 }}>
362
- {#each item.children as child}
375
+ <ul
376
+ class="mt-1 space-y-1 pl-4 overflow-hidden"
377
+ transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
378
+ >
379
+ {#each item.children ?? [] as child (child.id)}
363
380
  <li>
364
- <a
365
- href={child.disabled ? undefined : child.href}
366
- class={cn(
367
- 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
368
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
369
- getItemStateClasses(child.active, child.disabled)
370
- )}
371
- target={child.external ? '_blank' : undefined}
372
- rel={child.external ? 'noopener noreferrer' : undefined}
373
- aria-current={child.active ? 'page' : undefined}
374
- aria-disabled={child.disabled || undefined}
375
- tabindex={child.disabled ? -1 : undefined}
376
- onclick={(e) => {
377
- if (child.disabled) {
378
- e.preventDefault();
379
- return;
380
- }
381
- onNavigate?.(child);
382
- }}
383
- >
384
- {@render navIcon(child)}
385
- {@render highlightedName(child.name, highlightText(child.name, searchQuery))}
386
- {@render navBadge(child.badge)}
387
- {#if child.external}{@render externalIndicator()}{/if}
388
- </a>
381
+ {#if child.children?.length}
382
+ <!-- A nested category: same expandable row, one level deeper -->
383
+ <SidebarNavItem
384
+ item={child}
385
+ {isLight}
386
+ {isMobile}
387
+ {icon}
388
+ {searchQuery}
389
+ {onNavigate}
390
+ {autoCollapse}
391
+ siblingIds={expandableChildIds}
392
+ />
393
+ {:else}
394
+ <a
395
+ href={child.disabled ? undefined : child.href}
396
+ class={cn(
397
+ 'flex min-w-0 items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
398
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
399
+ getItemStateClasses(child.active, child.disabled)
400
+ )}
401
+ target={child.external ? '_blank' : undefined}
402
+ rel={child.external ? 'noopener noreferrer' : undefined}
403
+ aria-current={child.active ? 'page' : undefined}
404
+ aria-disabled={child.disabled || undefined}
405
+ tabindex={child.disabled ? -1 : undefined}
406
+ onclick={(e) => {
407
+ if (child.disabled) {
408
+ e.preventDefault();
409
+ return;
410
+ }
411
+ onNavigate?.(child);
412
+ }}
413
+ >
414
+ {@render navIcon(child)}
415
+ {@render highlightedName(child.name, highlightText(child.name, searchQuery))}
416
+ {@render navBadge(child.badge)}
417
+ {#if child.external}{@render externalIndicator()}{/if}
418
+ </a>
419
+ {/if}
389
420
  </li>
390
421
  {/each}
391
422
  </ul>
@@ -408,7 +439,7 @@
408
439
  <a
409
440
  href={item.disabled ? undefined : item.href}
410
441
  class={cn(
411
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium relative',
442
+ 'flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-sm font-medium relative',
412
443
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
413
444
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
414
445
  isLight && item.active && 'text-primary',
@@ -455,7 +486,7 @@
455
486
  <button
456
487
  type="button"
457
488
  class={cn(
458
- 'flex flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left relative',
489
+ 'flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-sm font-medium text-left relative',
459
490
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
460
491
  isLight && !item.active && 'text-foreground group-hover:text-accent-foreground',
461
492
  isLight && item.active && 'text-primary',
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { NavItem } from '../../../types/layout.js';
3
+ import SidebarNavItem from './SidebarNavItem.svelte';
3
4
  interface Props {
4
5
  /** The navigation item to render */
5
6
  item: NavItem;
@@ -27,6 +28,10 @@ interface Props {
27
28
  onMouseEnter?: (item: NavItem, event: MouseEvent) => void;
28
29
  /** Callback when mouse leaves item (for flyout) */
29
30
  onMouseLeave?: () => void;
31
+ /** Expanding this item closes `siblingIds`, and its children behave the same way */
32
+ autoCollapse?: boolean;
33
+ /** IDs of expandable items alongside this one */
34
+ siblingIds?: string[];
30
35
  }
31
36
  declare const SidebarNavItem: import("svelte").Component<Props, {}, "">;
32
37
  type SidebarNavItem = ReturnType<typeof SidebarNavItem>;
@@ -36,6 +36,10 @@
36
36
  onItemMouseEnter?: (item: NavItem, event: MouseEvent) => void;
37
37
  /** Callback when mouse leaves an item (for flyout) */
38
38
  onItemMouseLeave?: () => void;
39
+ /** Opening this section (or an item in it) closes its siblings */
40
+ autoCollapse?: boolean;
41
+ /** IDs of the sections alongside this one, closed on expand when `autoCollapse` is set */
42
+ siblingSectionIds?: string[];
39
43
  }
40
44
 
41
45
  let {
@@ -51,12 +55,19 @@
51
55
  onNavigate,
52
56
  onItemMouseEnter,
53
57
  onItemMouseLeave,
58
+ autoCollapse = false,
59
+ siblingSectionIds = [],
54
60
  }: Props = $props();
55
61
 
62
+ // Items here that can expand: the siblings an expanding item closes under autoCollapse
63
+ const expandableItemIds = $derived(
64
+ autoCollapse ? section.items.filter((i) => i.children?.length).map((i) => i.id) : []
65
+ );
66
+
56
67
  const isExpanded = $derived(sidebarStore.isSectionExpanded(section.id));
57
68
 
58
69
  function toggleSection() {
59
- sidebarStore.toggleSection(section.id);
70
+ sidebarStore.toggleSection(section.id, autoCollapse ? siblingSectionIds : []);
60
71
  }
61
72
  </script>
62
73
 
@@ -67,7 +78,8 @@
67
78
  <button
68
79
  onclick={toggleSection}
69
80
  class={cn(
70
- 'flex w-full items-center justify-between mb-2 px-4 text-xs font-semibold uppercase tracking-wider rounded-md overflow-hidden',
81
+ // min-h-8: a 32px target (WCAG 2.5.8 asks 24px); the text-xs line alone is 16px
82
+ 'flex min-h-8 w-full items-center justify-between mb-1 px-4 text-xs font-semibold uppercase tracking-wider rounded-md overflow-hidden',
71
83
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
72
84
  isLight
73
85
  ? 'text-muted-foreground hover:text-foreground'
@@ -79,7 +91,8 @@
79
91
  <span class="truncate">{section.title}</span>
80
92
  <svg
81
93
  class={cn(
82
- 'h-4 w-4 shrink-0 transition-transform duration-200',
94
+ 'h-4 w-4 shrink-0',
95
+ sidebarStore.animate && 'transition-transform duration-200',
83
96
  isExpanded ? 'rotate-0' : '-rotate-90'
84
97
  )}
85
98
  fill="none"
@@ -125,6 +138,8 @@
125
138
  {onNavigate}
126
139
  onMouseEnter={onItemMouseEnter}
127
140
  onMouseLeave={onItemMouseLeave}
141
+ {autoCollapse}
142
+ siblingIds={expandableItemIds}
128
143
  />
129
144
  </li>
130
145
  {/each}
@@ -134,7 +149,7 @@
134
149
  <ul
135
150
  class="space-y-1 px-2 overflow-hidden"
136
151
  id={`sidebar-section-${section.id}`}
137
- transition:slide={{ duration: 200 }}
152
+ transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
138
153
  >
139
154
  {#each section.items as item}
140
155
  <li>
@@ -151,6 +166,8 @@
151
166
  {onNavigate}
152
167
  onMouseEnter={onItemMouseEnter}
153
168
  onMouseLeave={onItemMouseLeave}
169
+ {autoCollapse}
170
+ siblingIds={expandableItemIds}
154
171
  />
155
172
  </li>
156
173
  {/each}
@@ -27,6 +27,10 @@ interface Props {
27
27
  onItemMouseEnter?: (item: NavItem, event: MouseEvent) => void;
28
28
  /** Callback when mouse leaves an item (for flyout) */
29
29
  onItemMouseLeave?: () => void;
30
+ /** Opening this section (or an item in it) closes its siblings */
31
+ autoCollapse?: boolean;
32
+ /** IDs of the sections alongside this one, closed on expand when `autoCollapse` is set */
33
+ siblingSectionIds?: string[];
30
34
  }
31
35
  declare const SidebarSection: import("svelte").Component<Props, {}, "">;
32
36
  type SidebarSection = ReturnType<typeof SidebarSection>;
@@ -7,6 +7,7 @@
7
7
  * All components use Svelte 5 runes syntax ($props, $state, $derived, etc.)
8
8
  */
9
9
  export { default as Button } from './components/Button.svelte';
10
+ export { buttonVariants, type ButtonVariants } from './components/button-variants.js';
10
11
  export { default as Input } from './components/Input.svelte';
11
12
  export { default as PasswordInput } from './components/PasswordInput.svelte';
12
13
  export { default as Label } from './components/Label.svelte';