@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.
- package/dist/__tests__/alert-dialog-dismiss.test.js +54 -0
- package/dist/__tests__/dropdown-trigger.test.js +30 -0
- package/dist/__tests__/public-layout-drawer.test.js +116 -0
- package/dist/__tests__/sidebar-docs-parity.test.js +134 -0
- package/dist/__tests__/sidebar-items.test.js +59 -0
- package/dist/lib/components/AlertDialog.svelte +19 -2
- package/dist/lib/components/Button.svelte +1 -31
- package/dist/lib/components/Button.svelte.d.ts +9 -6
- package/dist/lib/components/DataPanel.svelte +13 -3
- package/dist/lib/components/DropdownMenu.svelte +18 -1
- package/dist/lib/components/DropdownMenu.svelte.d.ts +8 -0
- package/dist/lib/components/button-variants.d.ts +119 -0
- package/dist/lib/components/button-variants.js +29 -0
- package/dist/lib/components/layout/DashboardLayout.svelte +10 -0
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +4 -0
- package/dist/lib/components/layout/Header.svelte +3 -1
- package/dist/lib/components/layout/PublicLayout.svelte +151 -44
- package/dist/lib/components/layout/PublicLayout.svelte.d.ts +8 -10
- package/dist/lib/components/layout/Sidebar.svelte +45 -27
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +5 -0
- package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +54 -32
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +62 -35
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +5 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +16 -1
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +4 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/stores/sidebar.svelte.d.ts +4 -4
- package/dist/lib/stores/sidebar.svelte.js +12 -4
- 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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
161
|
-
{
|
|
162
|
-
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
158
|
-
for (const item of
|
|
163
|
+
const find = (items: NavItem[]): NavItem | undefined => {
|
|
164
|
+
for (const item of items) {
|
|
159
165
|
if (item.id === itemId) return item;
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 */
|