@classic-homes/theme-svelte 1.15.3 → 1.17.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 (30) hide show
  1. package/dist/__tests__/alert-dialog-dismiss.test.js +54 -0
  2. package/dist/__tests__/dropdown-trigger.test.js +30 -0
  3. package/dist/__tests__/public-layout-drawer.test.js +116 -0
  4. package/dist/__tests__/sidebar-docs-parity.test.js +134 -0
  5. package/dist/__tests__/sidebar-items.test.js +59 -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/DataPanel.svelte +13 -3
  10. package/dist/lib/components/DropdownMenu.svelte +18 -1
  11. package/dist/lib/components/DropdownMenu.svelte.d.ts +8 -0
  12. package/dist/lib/components/button-variants.d.ts +119 -0
  13. package/dist/lib/components/button-variants.js +29 -0
  14. package/dist/lib/components/layout/DashboardLayout.svelte +10 -0
  15. package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +4 -0
  16. package/dist/lib/components/layout/Header.svelte +3 -1
  17. package/dist/lib/components/layout/PublicLayout.svelte +151 -44
  18. package/dist/lib/components/layout/PublicLayout.svelte.d.ts +8 -10
  19. package/dist/lib/components/layout/Sidebar.svelte +45 -27
  20. package/dist/lib/components/layout/Sidebar.svelte.d.ts +5 -0
  21. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +54 -32
  22. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +62 -35
  23. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +5 -0
  24. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +16 -1
  25. package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +4 -0
  26. package/dist/lib/index.d.ts +1 -0
  27. package/dist/lib/index.js +1 -0
  28. package/dist/lib/stores/sidebar.svelte.d.ts +4 -4
  29. package/dist/lib/stores/sidebar.svelte.js +12 -4
  30. package/package.json +1 -1
@@ -0,0 +1,119 @@
1
+ import { type VariantProps } from 'tailwind-variants';
2
+ /**
3
+ * Button styles, shared by Button and anything that renders a button-like
4
+ * control it cannot nest a Button inside (e.g. DropdownMenu's trigger).
5
+ */
6
+ export declare const buttonVariants: import("tailwind-variants").TVReturnType<{
7
+ variant: {
8
+ default: string;
9
+ secondary: string;
10
+ destructive: string;
11
+ outline: string;
12
+ ghost: string;
13
+ link: string;
14
+ };
15
+ size: {
16
+ default: string;
17
+ sm: string;
18
+ md: string;
19
+ lg: string;
20
+ icon: string;
21
+ };
22
+ }, undefined, "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
23
+ variant: {
24
+ default: string;
25
+ secondary: string;
26
+ destructive: string;
27
+ outline: string;
28
+ ghost: string;
29
+ link: string;
30
+ };
31
+ size: {
32
+ default: string;
33
+ sm: string;
34
+ md: string;
35
+ lg: string;
36
+ icon: string;
37
+ };
38
+ }, {
39
+ variant: {
40
+ default: string;
41
+ secondary: string;
42
+ destructive: string;
43
+ outline: string;
44
+ ghost: string;
45
+ link: string;
46
+ };
47
+ size: {
48
+ default: string;
49
+ sm: string;
50
+ md: string;
51
+ lg: string;
52
+ icon: string;
53
+ };
54
+ }>, {
55
+ variant: {
56
+ default: string;
57
+ secondary: string;
58
+ destructive: string;
59
+ outline: string;
60
+ ghost: string;
61
+ link: string;
62
+ };
63
+ size: {
64
+ default: string;
65
+ sm: string;
66
+ md: string;
67
+ lg: string;
68
+ icon: string;
69
+ };
70
+ }, undefined, import("tailwind-variants").TVReturnType<{
71
+ variant: {
72
+ default: string;
73
+ secondary: string;
74
+ destructive: string;
75
+ outline: string;
76
+ ghost: string;
77
+ link: string;
78
+ };
79
+ size: {
80
+ default: string;
81
+ sm: string;
82
+ md: string;
83
+ lg: string;
84
+ icon: string;
85
+ };
86
+ }, undefined, "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", import("tailwind-variants/dist/config.js").TVConfig<{
87
+ variant: {
88
+ default: string;
89
+ secondary: string;
90
+ destructive: string;
91
+ outline: string;
92
+ ghost: string;
93
+ link: string;
94
+ };
95
+ size: {
96
+ default: string;
97
+ sm: string;
98
+ md: string;
99
+ lg: string;
100
+ icon: string;
101
+ };
102
+ }, {
103
+ variant: {
104
+ default: string;
105
+ secondary: string;
106
+ destructive: string;
107
+ outline: string;
108
+ ghost: string;
109
+ link: string;
110
+ };
111
+ size: {
112
+ default: string;
113
+ sm: string;
114
+ md: string;
115
+ lg: string;
116
+ icon: string;
117
+ };
118
+ }>, unknown, unknown, undefined>>;
119
+ export type ButtonVariants = VariantProps<typeof buttonVariants>;
@@ -0,0 +1,29 @@
1
+ import { tv } from 'tailwind-variants';
2
+ /**
3
+ * Button styles, shared by Button and anything that renders a button-like
4
+ * control it cannot nest a Button inside (e.g. DropdownMenu's trigger).
5
+ */
6
+ export const buttonVariants = tv({
7
+ base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50',
8
+ variants: {
9
+ variant: {
10
+ default: 'bg-primary text-primary-foreground shadow hover:bg-primary-hover',
11
+ secondary: 'border-2 border-secondary-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary-hover',
12
+ destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover focus-visible:ring-destructive',
13
+ outline: 'border-2 border-outline bg-transparent text-foreground hover:border-outline-hover hover:bg-primary hover:text-primary-foreground',
14
+ ghost: 'hover:bg-muted-hover hover:text-foreground',
15
+ link: 'text-primary underline-offset-4 hover:underline',
16
+ },
17
+ size: {
18
+ default: 'h-10 px-4 py-2',
19
+ sm: 'h-9 rounded-md px-3 text-sm',
20
+ md: 'h-10 px-4 py-2',
21
+ lg: 'h-11 rounded-md px-8',
22
+ icon: 'h-10 w-10',
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ variant: 'default',
27
+ size: 'default',
28
+ },
29
+ });
@@ -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,
@@ -269,6 +275,7 @@
269
275
  {quickLinksDisplay}
270
276
  quickLinkIcon={quickLinkIcon ?? (icon as Snippet<[QuickLink]> | undefined)}
271
277
  footer={sidebarFooter}
278
+ autoCollapse={sidebarAutoCollapse}
272
279
  {expandedWidth}
273
280
  {collapsedWidth}
274
281
  {searchable}
@@ -334,5 +341,8 @@
334
341
  >
335
342
  {@render children()}
336
343
  </main>
344
+ {#if footer}
345
+ {@render footer()}
346
+ {/if}
337
347
  </div>
338
348
  </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) */
@@ -283,7 +283,9 @@
283
283
 
284
284
  <!-- Mobile Navigation Drawer (appears below header bar when open) -->
285
285
  {#if mobileNav && menuOpen}
286
- <nav class={cn(bp.mobileNavHide)} aria-label="Mobile navigation">
286
+ <!-- Solid, unlike the translucent bar: the dropdown overlays the page once
287
+ the sticky header has scrolled -->
288
+ <nav class={cn(bp.mobileNavHide, 'bg-background')} aria-label="Mobile navigation">
287
289
  {@render mobileNav()}
288
290
  </nav>
289
291
  {/if}
@@ -8,7 +8,10 @@
8
8
  * - Main content area
9
9
  * - Responsive design
10
10
  * - Strong border options for brand consistency
11
+ * - Mobile nav as a dropdown under the header bar, or (`mobileNavStyle="drawer"`)
12
+ * the slide-out Sidebar drawer DashboardLayout apps use
11
13
  */
14
+ import { onMount } from 'svelte';
12
15
  import type { Snippet } from 'svelte';
13
16
  import type { NavItem, NavSection, HeaderSearchConfig } from '../../types/layout.js';
14
17
  import { cn } from '../../utils.js';
@@ -16,6 +19,9 @@
16
19
  import Footer from './Footer.svelte';
17
20
  import Header from './Header.svelte';
18
21
  import LogoMain from '../LogoMain.svelte';
22
+ import Sidebar from './Sidebar.svelte';
23
+ import DropdownMenu from '../DropdownMenu.svelte';
24
+ import Icon, { type IconName } from '../Icon.svelte';
19
25
 
20
26
  type Breakpoint = 'sm' | 'md' | 'lg';
21
27
 
@@ -28,6 +34,14 @@
28
34
  * more than ~768px: at iPad portrait widths (820px) they otherwise overlap.
29
35
  */
30
36
  mobileBreakpoint?: Breakpoint;
37
+ /**
38
+ * How the hamburger opens the nav below `mobileBreakpoint`. `dropdown`
39
+ * (default) expands under the header bar, pushing the page down when the
40
+ * header is at the top. `drawer` slides the Sidebar in over the page with a
41
+ * scrim - the same drawer, focus trap, Escape and swipe-to-close as
42
+ * DashboardLayout apps.
43
+ */
44
+ mobileNavStyle?: 'dropdown' | 'drawer';
31
45
  /** Whether to show footer */
32
46
  showFooter?: boolean;
33
47
  /** Footer link sections */
@@ -64,6 +78,7 @@
64
78
  let {
65
79
  navigation = [],
66
80
  mobileBreakpoint = 'md',
81
+ mobileNavStyle = 'dropdown',
67
82
  showFooter = true,
68
83
  footerLinks = [],
69
84
  copyright,
@@ -81,6 +96,27 @@
81
96
 
82
97
  let mobileMenuOpen = $state(false);
83
98
 
99
+ const breakpointPx: Record<Breakpoint, number> = { sm: 640, md: 768, lg: 1024 };
100
+
101
+ // The drawer only exists below the breakpoint: the Sidebar is a fixed
102
+ // desktop column otherwise. Starts false, so SSR and the first paint render
103
+ // no drawer; the hamburger it pairs with is hidden above the breakpoint.
104
+ let belowBreakpoint = $state(false);
105
+ onMount(() => {
106
+ const query = window.matchMedia(`(max-width: ${breakpointPx[mobileBreakpoint] - 0.02}px)`);
107
+ const update = () => {
108
+ belowBreakpoint = query.matches;
109
+ if (!query.matches) mobileMenuOpen = false;
110
+ };
111
+ update();
112
+ query.addEventListener('change', update);
113
+ return () => query.removeEventListener('change', update);
114
+ });
115
+
116
+ const useDrawer = $derived(mobileNavStyle === 'drawer');
117
+ const drawerNavigation = $derived<NavSection[]>([{ id: 'main', items: navigation }]);
118
+ const closeMenu = () => (mobileMenuOpen = false);
119
+
84
120
  // Full class strings so Tailwind's scanner sees them.
85
121
  const desktopOnly: Record<Breakpoint, string> = {
86
122
  sm: 'hidden sm:contents',
@@ -89,6 +125,51 @@
89
125
  };
90
126
  </script>
91
127
 
128
+ <!-- One header nav link -->
129
+ {#snippet navLink(item: NavItem)}
130
+ <a
131
+ href={item.href}
132
+ class={cn(
133
+ // Touch target: p-3 around the 20px line box makes the anchor's own
134
+ // border box 44px tall, and -m-3 cancels it in layout so the nav's
135
+ // gap-6 is unchanged (adjacent boxes meet without overlapping).
136
+ '-m-3 flex items-center p-3 text-sm font-bold uppercase hover:text-primary',
137
+ // iPadOS reports hover:none even with a trackpad attached, so the preset's
138
+ // hoverOnlyWhenSupported gating means hover: never fires there. any-pointer:fine
139
+ // matches when any attached pointer is precise, giving trackpad users a cue.
140
+ '[@media(any-pointer:fine)]:[&:hover]:text-primary',
141
+ item.active ? 'text-primary' : 'text-muted-foreground'
142
+ )}
143
+ target={item.external ? '_blank' : undefined}
144
+ rel={item.external ? 'noopener noreferrer' : undefined}
145
+ aria-current={item.active ? 'page' : undefined}
146
+ >
147
+ {item.name}
148
+ </a>
149
+ {/snippet}
150
+
151
+ <!-- The drawer's footer: mobileNavEnd, closing the drawer after use -->
152
+ {#snippet drawerFooter()}
153
+ {@render mobileNavEnd?.({ close: closeMenu })}
154
+ {/snippet}
155
+
156
+ <!-- The drawer's logo: PublicLayout's own logo, closing the drawer on tap -->
157
+ {#snippet drawerLogo()}
158
+ <a href={logoHref} onclick={closeMenu} class="flex items-center">
159
+ {#if logo}
160
+ {@render logo()}
161
+ {:else}
162
+ <LogoMain
163
+ variant="horizontal"
164
+ color="dark"
165
+ size="md"
166
+ subtitle={logoSubtitle}
167
+ environment={logoEnvironment}
168
+ />
169
+ {/if}
170
+ </a>
171
+ {/snippet}
172
+
92
173
  <AppShell>
93
174
  <Header
94
175
  showMenuButton={navigation.length > 0}
@@ -123,25 +204,34 @@
123
204
 
124
205
  {#snippet nav()}
125
206
  {#each navigation as item}
126
- <a
127
- href={item.href}
128
- class={cn(
129
- // Touch target: p-3 around the 20px line box makes the anchor's own
130
- // border box 44px tall, and -m-3 cancels it in layout so the nav's
131
- // gap-6 is unchanged (adjacent boxes meet without overlapping).
132
- '-m-3 flex items-center p-3 text-sm font-bold uppercase hover:text-primary',
133
- // iPadOS reports hover:none even with a trackpad attached, so the preset's
134
- // hoverOnlyWhenSupported gating means hover: never fires there. any-pointer:fine
135
- // matches when any attached pointer is precise, giving trackpad users a cue.
136
- '[@media(any-pointer:fine)]:[&:hover]:text-primary',
137
- item.active ? 'text-primary' : 'text-muted-foreground'
138
- )}
139
- target={item.external ? '_blank' : undefined}
140
- rel={item.external ? 'noopener noreferrer' : undefined}
141
- aria-current={item.active ? 'page' : undefined}
142
- >
143
- {item.name}
144
- </a>
207
+ {#if item.children?.length}
208
+ <!-- Parent link plus a chevron menu of its children (e.g. the same
209
+ page opened in another view). The drawer shows the same children
210
+ as an expandable sub-list. -->
211
+ <div class="flex items-center gap-0.5">
212
+ {@render navLink(item)}
213
+ <DropdownMenu
214
+ items={item.children.map((child) => ({
215
+ label: child.name,
216
+ href: child.href,
217
+ icon: child.icon as IconName | undefined,
218
+ }))}
219
+ align="end"
220
+ triggerLabel={`${item.name} options`}
221
+ triggerClass={cn(
222
+ '-my-3 -mr-2 flex items-center rounded-md p-2 hover:text-primary',
223
+ '[@media(any-pointer:fine)]:[&:hover]:text-primary',
224
+ item.active ? 'text-primary' : 'text-muted-foreground'
225
+ )}
226
+ >
227
+ {#snippet trigger()}
228
+ <Icon name="chevron-down" size={16} aria-hidden="true" />
229
+ {/snippet}
230
+ </DropdownMenu>
231
+ </div>
232
+ {:else}
233
+ {@render navLink(item)}
234
+ {/if}
145
235
  {/each}
146
236
  {/snippet}
147
237
 
@@ -156,36 +246,53 @@
156
246
  {/if}
157
247
  {/snippet}
158
248
 
249
+ <!-- Dropdown style only; the drawer renders after the header -->
159
250
  {#snippet mobileNav()}
160
- <ul class={cn('space-y-2 px-4 py-4', !mobileNavEnd && 'border-b border-black')}>
161
- {#each navigation as item}
162
- <li>
163
- <a
164
- href={item.href}
165
- class={cn(
166
- 'block rounded-md px-3 py-3 text-sm font-bold uppercase',
167
- item.active
168
- ? 'bg-primary/10 text-primary'
169
- : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
170
- )}
171
- target={item.external ? '_blank' : undefined}
172
- rel={item.external ? 'noopener noreferrer' : undefined}
173
- aria-current={item.active ? 'page' : undefined}
174
- onclick={() => (mobileMenuOpen = false)}
175
- >
176
- {item.name}
177
- </a>
178
- </li>
179
- {/each}
180
- </ul>
181
- {#if mobileNavEnd}
182
- <div class="border-b border-black px-4 pb-4">
183
- {@render mobileNavEnd({ close: () => (mobileMenuOpen = false) })}
184
- </div>
251
+ {#if !useDrawer}
252
+ <ul class={cn('space-y-2 px-4 py-4', !mobileNavEnd && 'border-b border-black')}>
253
+ {#each navigation as item}
254
+ <li>
255
+ <a
256
+ href={item.href}
257
+ class={cn(
258
+ 'block rounded-md px-3 py-3 text-sm font-bold uppercase',
259
+ item.active
260
+ ? 'bg-primary/10 text-primary'
261
+ : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
262
+ )}
263
+ target={item.external ? '_blank' : undefined}
264
+ rel={item.external ? 'noopener noreferrer' : undefined}
265
+ aria-current={item.active ? 'page' : undefined}
266
+ onclick={() => (mobileMenuOpen = false)}
267
+ >
268
+ {item.name}
269
+ </a>
270
+ </li>
271
+ {/each}
272
+ </ul>
273
+ {#if mobileNavEnd}
274
+ <div class="border-b border-black px-4 pb-4">
275
+ {@render mobileNavEnd({ close: () => (mobileMenuOpen = false) })}
276
+ </div>
277
+ {/if}
185
278
  {/if}
186
279
  {/snippet}
187
280
  </Header>
188
281
 
282
+ {#if useDrawer && belowBreakpoint}
283
+ <!-- --z-sidebar 70 (scrim 60): over the elevated header (z-50) and any
284
+ page-level sheet or panel, which sit at z-50 and below -->
285
+ <div class="contents" style="--z-sidebar: 70">
286
+ <Sidebar
287
+ navigation={drawerNavigation}
288
+ isMobile
289
+ mobileOpen={mobileMenuOpen}
290
+ onClose={closeMenu}
291
+ logo={drawerLogo}
292
+ footer={mobileNavEnd ? drawerFooter : undefined}
293
+ />
294
+ </div>
295
+ {/if}
189
296
  <!-- Main Content -->
190
297
  <main id="main-content" class="flex-1 bg-content-bg">
191
298
  {@render children()}
@@ -1,13 +1,3 @@
1
- /**
2
- * PublicLayout - Layout for public-facing pages
3
- *
4
- * Features:
5
- * - Header with navigation (uppercase bold links)
6
- * - Footer with link sections
7
- * - Main content area
8
- * - Responsive design
9
- * - Strong border options for brand consistency
10
- */
11
1
  import type { Snippet } from 'svelte';
12
2
  import type { NavItem, NavSection, HeaderSearchConfig } from '../../types/layout.js';
13
3
  type Breakpoint = 'sm' | 'md' | 'lg';
@@ -20,6 +10,14 @@ interface Props {
20
10
  * more than ~768px: at iPad portrait widths (820px) they otherwise overlap.
21
11
  */
22
12
  mobileBreakpoint?: Breakpoint;
13
+ /**
14
+ * How the hamburger opens the nav below `mobileBreakpoint`. `dropdown`
15
+ * (default) expands under the header bar, pushing the page down when the
16
+ * header is at the top. `drawer` slides the Sidebar in over the page with a
17
+ * scrim - the same drawer, focus trap, Escape and swipe-to-close as
18
+ * DashboardLayout apps.
19
+ */
20
+ mobileNavStyle?: 'dropdown' | 'drawer';
23
21
  /** Whether to show footer */
24
22
  showFooter?: boolean;
25
23
  /** Footer link sections */
@@ -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);
@@ -458,11 +469,16 @@
458
469
  }
459
470
  </script>
460
471
 
461
- <!-- Mobile Overlay: z-40 positions below sidebar (z-50) but above main content.
472
+ <!-- Mobile Overlay: 10 below the sidebar (--z-sidebar, default 50, so z-40) but
473
+ above main content; a host that lifts --z-sidebar lifts the scrim with it.
462
474
  Not focusable: the drawer traps focus and handles Escape itself. -->
463
475
  {#if isMobile && mobileOpen}
464
476
  <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
465
- <div class="fixed inset-0 z-40 bg-black/50" onclick={() => onClose?.()} aria-hidden="true"></div>
477
+ <div
478
+ class="fixed inset-0 z-[calc(var(--z-sidebar,50)_-_10)] bg-black/50"
479
+ onclick={() => onClose?.()}
480
+ aria-hidden="true"
481
+ ></div>
466
482
  {/if}
467
483
 
468
484
  <!-- Sidebar: z-50 positions above overlay and content, below modals (z-100+) -->
@@ -561,6 +577,8 @@
561
577
  onNavigate={handleNavigate}
562
578
  onItemMouseEnter={handleItemMouseEnter}
563
579
  onItemMouseLeave={handleItemMouseLeave}
580
+ autoCollapse={autoCollapse && !searchQuery.trim()}
581
+ siblingSectionIds={collapsibleSectionIds}
564
582
  />
565
583
  {:else}
566
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 */