@classic-homes/theme-svelte 1.17.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__/dropdown-trigger.test.js +9 -0
- package/dist/__tests__/sidebar-mobile.test.js +13 -0
- package/dist/lib/components/DropdownMenu.svelte +17 -0
- package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
- package/dist/lib/components/layout/DashboardLayout.svelte +34 -12
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +6 -2
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +5 -3
- package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
- package/dist/lib/stores/sidebar.svelte.js +13 -0
- package/package.json +1 -1
|
@@ -27,4 +27,13 @@ describe('DropdownMenu trigger', () => {
|
|
|
27
27
|
}
|
|
28
28
|
expect(button.classList.contains('custom')).toBe(true);
|
|
29
29
|
});
|
|
30
|
+
it('adds a disclosure chevron only with showChevron', () => {
|
|
31
|
+
const { unmount } = render(DropdownMenu, { props: { items, trigger } });
|
|
32
|
+
expect(screen.getByRole('button', { name: 'Menu' }).querySelector('svg')).toBeNull();
|
|
33
|
+
unmount();
|
|
34
|
+
render(DropdownMenu, { props: { items, trigger, triggerVariant: 'ghost', showChevron: true } });
|
|
35
|
+
const chevron = screen.getByRole('button', { name: 'Menu' }).querySelector('svg');
|
|
36
|
+
expect(chevron).not.toBeNull();
|
|
37
|
+
expect(chevron?.getAttribute('aria-hidden')).toBe('true');
|
|
38
|
+
});
|
|
30
39
|
});
|
|
@@ -70,4 +70,17 @@ describe('sidebar mobile semantics', () => {
|
|
|
70
70
|
sidebarStore.setMobile(false);
|
|
71
71
|
expect(sidebarStore.isOpen).toBe(false);
|
|
72
72
|
});
|
|
73
|
+
it('does not animate expansion until the frames after initialize', async () => {
|
|
74
|
+
const appId = freshAppId();
|
|
75
|
+
localStorage.setItem(`classic-theme-sidebar-${appId}-sections`, JSON.stringify(['a']));
|
|
76
|
+
// A fresh store instance would start false; the singleton may already be settled
|
|
77
|
+
// from earlier tests, so check the reset-on-initialize path via a new appId.
|
|
78
|
+
sidebarStore.animate = false;
|
|
79
|
+
sidebarStore.initialize(appId);
|
|
80
|
+
expect(sidebarStore.isSectionExpanded('a')).toBe(true);
|
|
81
|
+
expect(sidebarStore.animate).toBe(false);
|
|
82
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
84
|
+
expect(sidebarStore.animate).toBe(true);
|
|
85
|
+
});
|
|
73
86
|
});
|
|
@@ -50,6 +50,11 @@
|
|
|
50
50
|
triggerSize?: ButtonVariants['size'];
|
|
51
51
|
/** Accessible name for the trigger `<button>` (for icon-only triggers) */
|
|
52
52
|
triggerLabel?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Show a disclosure chevron after the trigger content, turned while the menu is open,
|
|
55
|
+
* so a text trigger (e.g. header navigation) reads as a menu rather than a link.
|
|
56
|
+
*/
|
|
57
|
+
showChevron?: boolean;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
let {
|
|
@@ -64,6 +69,7 @@
|
|
|
64
69
|
triggerVariant,
|
|
65
70
|
triggerSize = 'sm',
|
|
66
71
|
triggerLabel,
|
|
72
|
+
showChevron = false,
|
|
67
73
|
}: Props = $props();
|
|
68
74
|
|
|
69
75
|
function handleOpenChange(newOpen: boolean) {
|
|
@@ -130,6 +136,17 @@
|
|
|
130
136
|
aria-label={triggerLabel}
|
|
131
137
|
>
|
|
132
138
|
{@render trigger()}
|
|
139
|
+
{#if showChevron}
|
|
140
|
+
<Icon
|
|
141
|
+
name="chevron-down"
|
|
142
|
+
size={16}
|
|
143
|
+
class={cn(
|
|
144
|
+
'ml-1 shrink-0 opacity-60 transition-transform duration-200 motion-reduce:transition-none',
|
|
145
|
+
open && 'rotate-180'
|
|
146
|
+
)}
|
|
147
|
+
aria-hidden="true"
|
|
148
|
+
/>
|
|
149
|
+
{/if}
|
|
133
150
|
</button>
|
|
134
151
|
{/snippet}
|
|
135
152
|
</DropdownMenuPrimitive.Trigger>
|
|
@@ -44,6 +44,11 @@ interface Props {
|
|
|
44
44
|
triggerSize?: ButtonVariants['size'];
|
|
45
45
|
/** Accessible name for the trigger `<button>` (for icon-only triggers) */
|
|
46
46
|
triggerLabel?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Show a disclosure chevron after the trigger content, turned while the menu is open,
|
|
49
|
+
* so a text trigger (e.g. header navigation) reads as a menu rather than a link.
|
|
50
|
+
*/
|
|
51
|
+
showChevron?: boolean;
|
|
47
52
|
}
|
|
48
53
|
declare const DropdownMenu: import("svelte").Component<Props, {}, "open">;
|
|
49
54
|
type DropdownMenu = ReturnType<typeof DropdownMenu>;
|
|
@@ -165,11 +165,10 @@
|
|
|
165
165
|
: navigation
|
|
166
166
|
);
|
|
167
167
|
|
|
168
|
-
// Reactive media query for mobile detection. Initialized synchronously so
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
// 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.
|
|
173
172
|
// svelte-ignore state_referenced_locally
|
|
174
173
|
const initialIsMobile =
|
|
175
174
|
typeof window !== 'undefined' &&
|
|
@@ -179,12 +178,23 @@
|
|
|
179
178
|
sidebarStore.setMobile(initialIsMobile);
|
|
180
179
|
}
|
|
181
180
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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.
|
|
184
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);
|
|
185
189
|
|
|
186
190
|
$effect(() => {
|
|
187
191
|
mounted = true;
|
|
192
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
193
|
+
animate = true;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const frame = requestAnimationFrame(() => (animate = true));
|
|
197
|
+
return () => cancelAnimationFrame(frame);
|
|
188
198
|
});
|
|
189
199
|
|
|
190
200
|
$effect(() => {
|
|
@@ -252,6 +262,14 @@
|
|
|
252
262
|
isMobile ? 0 : sidebarStore.isOpen ? expandedWidth : collapsedWidth
|
|
253
263
|
);
|
|
254
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
|
+
|
|
255
273
|
const contentPaddingClass = $derived(
|
|
256
274
|
contentPadding === 'none' ? '' : contentPadding === 'compact' ? 'p-2 lg:p-4' : 'p-4 lg:p-6'
|
|
257
275
|
);
|
|
@@ -286,21 +304,25 @@
|
|
|
286
304
|
{onPinnedChange}
|
|
287
305
|
{pinIcon}
|
|
288
306
|
{appId}
|
|
307
|
+
class={mounted ? undefined : ssr.sidebar}
|
|
289
308
|
/>
|
|
290
309
|
|
|
291
310
|
<!-- Main Content Area -->
|
|
292
311
|
<div
|
|
293
|
-
class="flex flex-1 flex-col {mounted
|
|
312
|
+
class="flex flex-1 flex-col {mounted ? '' : ssr.content} {animate
|
|
294
313
|
? 'transition-[margin] duration-300 motion-reduce:transition-none'
|
|
295
314
|
: ''}"
|
|
296
|
-
style=
|
|
315
|
+
style={mounted
|
|
316
|
+
? `margin-left: ${sidebarWidth}px;`
|
|
317
|
+
: `--dl-sidebar-width: ${sidebarCollapsed ? collapsedWidth : expandedWidth}px;`}
|
|
297
318
|
>
|
|
298
319
|
{#if showHeader}
|
|
299
320
|
<Header
|
|
300
|
-
|
|
301
|
-
|
|
321
|
+
{mobileBreakpoint}
|
|
322
|
+
showMenuButton={isMobile || !mounted}
|
|
323
|
+
menuOpen={mounted && isMobile && sidebarStore.isOpen}
|
|
302
324
|
onMenuClick={() => sidebarStore.toggle()}
|
|
303
|
-
showCollapseButton={!isMobile}
|
|
325
|
+
showCollapseButton={!isMobile || !mounted}
|
|
304
326
|
sidebarCollapsed={!sidebarStore.isOpen}
|
|
305
327
|
onCollapseClick={() => sidebarStore.toggle()}
|
|
306
328
|
title={pageTitle}
|
|
@@ -346,7 +346,8 @@
|
|
|
346
346
|
>
|
|
347
347
|
<svg
|
|
348
348
|
class={cn(
|
|
349
|
-
'h-4 w-4
|
|
349
|
+
'h-4 w-4',
|
|
350
|
+
sidebarStore.animate && 'transition-transform duration-200',
|
|
350
351
|
isExpanded ? 'rotate-0' : '-rotate-90'
|
|
351
352
|
)}
|
|
352
353
|
fill="none"
|
|
@@ -371,7 +372,10 @@
|
|
|
371
372
|
</div>
|
|
372
373
|
|
|
373
374
|
{#if isExpanded}
|
|
374
|
-
<ul
|
|
375
|
+
<ul
|
|
376
|
+
class="mt-1 space-y-1 pl-4 overflow-hidden"
|
|
377
|
+
transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
|
|
378
|
+
>
|
|
375
379
|
{#each item.children ?? [] as child (child.id)}
|
|
376
380
|
<li>
|
|
377
381
|
{#if child.children?.length}
|
|
@@ -78,7 +78,8 @@
|
|
|
78
78
|
<button
|
|
79
79
|
onclick={toggleSection}
|
|
80
80
|
class={cn(
|
|
81
|
-
|
|
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',
|
|
82
83
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
|
83
84
|
isLight
|
|
84
85
|
? 'text-muted-foreground hover:text-foreground'
|
|
@@ -90,7 +91,8 @@
|
|
|
90
91
|
<span class="truncate">{section.title}</span>
|
|
91
92
|
<svg
|
|
92
93
|
class={cn(
|
|
93
|
-
'h-4 w-4 shrink-0
|
|
94
|
+
'h-4 w-4 shrink-0',
|
|
95
|
+
sidebarStore.animate && 'transition-transform duration-200',
|
|
94
96
|
isExpanded ? 'rotate-0' : '-rotate-90'
|
|
95
97
|
)}
|
|
96
98
|
fill="none"
|
|
@@ -147,7 +149,7 @@
|
|
|
147
149
|
<ul
|
|
148
150
|
class="space-y-1 px-2 overflow-hidden"
|
|
149
151
|
id={`sidebar-section-${section.id}`}
|
|
150
|
-
transition:slide={{ duration: 200 }}
|
|
152
|
+
transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
|
|
151
153
|
>
|
|
152
154
|
{#each section.items as item}
|
|
153
155
|
<li>
|
|
@@ -13,6 +13,12 @@ declare class SidebarStore {
|
|
|
13
13
|
expandedSections: Set<string>;
|
|
14
14
|
expandedItems: Set<string>;
|
|
15
15
|
pinnedItems: Set<string>;
|
|
16
|
+
/**
|
|
17
|
+
* Whether expand/collapse should animate. False until the frame after `initialize`,
|
|
18
|
+
* so restoring saved sections (and any defaults or reveals applied on mount) appears
|
|
19
|
+
* at once instead of every section sliding open on each page load.
|
|
20
|
+
*/
|
|
21
|
+
animate: boolean;
|
|
16
22
|
/**
|
|
17
23
|
* Whether the store has been initialized
|
|
18
24
|
*/
|
|
@@ -27,6 +27,12 @@ class SidebarStore {
|
|
|
27
27
|
this.expandedSections = $state(new Set());
|
|
28
28
|
this.expandedItems = $state(new Set());
|
|
29
29
|
this.pinnedItems = $state(new Set());
|
|
30
|
+
/**
|
|
31
|
+
* Whether expand/collapse should animate. False until the frame after `initialize`,
|
|
32
|
+
* so restoring saved sections (and any defaults or reveals applied on mount) appears
|
|
33
|
+
* at once instead of every section sliding open on each page load.
|
|
34
|
+
*/
|
|
35
|
+
this.animate = $state(false);
|
|
30
36
|
_SidebarStore_initialized.set(this, false);
|
|
31
37
|
_SidebarStore_loadedFromStorage.set(this, false);
|
|
32
38
|
_SidebarStore_loadedExpansionFromStorage.set(this, false);
|
|
@@ -114,6 +120,13 @@ class SidebarStore {
|
|
|
114
120
|
catch {
|
|
115
121
|
// localStorage not available
|
|
116
122
|
}
|
|
123
|
+
// Two frames: effects that apply defaults run after this one, before the next paint.
|
|
124
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
125
|
+
requestAnimationFrame(() => requestAnimationFrame(() => (this.animate = true)));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
this.animate = true;
|
|
129
|
+
}
|
|
117
130
|
}
|
|
118
131
|
/**
|
|
119
132
|
* Get the current app ID
|