@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.
- package/dist/__tests__/alert-dialog-dismiss.test.js +54 -0
- package/dist/__tests__/dropdown-trigger.test.js +39 -0
- package/dist/__tests__/sidebar-docs-parity.test.js +134 -0
- package/dist/__tests__/sidebar-items.test.js +59 -0
- package/dist/__tests__/sidebar-mobile.test.js +13 -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/DropdownMenu.svelte +35 -1
- package/dist/lib/components/DropdownMenu.svelte.d.ts +13 -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 +44 -12
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +4 -0
- package/dist/lib/components/layout/Sidebar.svelte +38 -25
- 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 +68 -37
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +5 -0
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +21 -4
- 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 +10 -4
- package/dist/lib/stores/sidebar.svelte.js +25 -4
- 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
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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
|
-
//
|
|
177
|
-
//
|
|
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=
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
-
{
|
|
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
|
-
<
|
|
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
|
|
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
|
|
362
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
|
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>;
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -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';
|