@classic-homes/theme-svelte 1.8.1 → 1.9.1
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__/a11y/PublicLayout.a11y.test.js +13 -2
- package/dist/__tests__/sidebar-collapse.test.js +58 -0
- package/dist/lib/components/layout/DashboardLayout.svelte +12 -7
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +1 -1
- package/dist/lib/components/layout/PublicLayout.svelte +19 -5
- package/dist/lib/components/layout/PublicLayout.svelte.d.ts +7 -0
- package/dist/lib/stores/sidebar.svelte.d.ts +2 -1
- package/dist/lib/stores/sidebar.svelte.js +5 -1
- package/package.json +1 -1
|
@@ -54,8 +54,7 @@ describe('PublicLayout accessibility', () => {
|
|
|
54
54
|
expect(links).toHaveLength(navigation.length);
|
|
55
55
|
for (const link of links) {
|
|
56
56
|
// 20px text line box + 2 x 12px padding = 44px border box; -m-3 cancels
|
|
57
|
-
// it in layout
|
|
58
|
-
// hit-testing missed the pseudo-element but honours padding.
|
|
57
|
+
// it in layout so the nav's gap-6 spacing is unchanged.
|
|
59
58
|
expect(link.className).toContain('p-3');
|
|
60
59
|
expect(link.className).toContain('-m-3');
|
|
61
60
|
expect(link.className).not.toContain('after:');
|
|
@@ -99,6 +98,18 @@ describe('PublicLayout accessibility', () => {
|
|
|
99
98
|
expect(cta.parentElement?.className).toContain('hidden');
|
|
100
99
|
expect(cta.parentElement?.className).toContain('md:contents');
|
|
101
100
|
});
|
|
101
|
+
it('should switch the header to the drawer at the given mobileBreakpoint', () => {
|
|
102
|
+
renderLayout({
|
|
103
|
+
mobileBreakpoint: 'lg',
|
|
104
|
+
headerEnd: textSnippet('Service Request'),
|
|
105
|
+
mobileNavEnd: ctaSnippet('Drawer CTA'),
|
|
106
|
+
});
|
|
107
|
+
const nav = screen.getByRole('navigation', { name: 'Main navigation' });
|
|
108
|
+
expect(nav.className).toContain('lg:flex');
|
|
109
|
+
expect(nav.className).not.toContain('md:flex');
|
|
110
|
+
expect(screen.getByRole('button', { name: 'Open menu' }).className).toContain('lg:hidden');
|
|
111
|
+
expect(screen.getByText('Service Request').parentElement?.className).toContain('lg:contents');
|
|
112
|
+
});
|
|
102
113
|
it('should render mobileNavEnd in the drawer and close it via the close argument', async () => {
|
|
103
114
|
renderLayout({
|
|
104
115
|
headerEnd: textSnippet('Service Request'),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar Collapse Tests
|
|
3
|
+
*
|
|
4
|
+
* DashboardLayout applies its `sidebarCollapsed` default only when no open
|
|
5
|
+
* state is persisted. A first session (nothing in localStorage yet) must
|
|
6
|
+
* still let the user collapse the sidebar — the default must not re-apply
|
|
7
|
+
* on every toggle and snap it back open.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
11
|
+
import { createRawSnippet, flushSync } from 'svelte';
|
|
12
|
+
import DashboardLayout from '../lib/components/layout/DashboardLayout.svelte';
|
|
13
|
+
import { sidebarStore } from '../lib/stores/sidebar.svelte.js';
|
|
14
|
+
let appCounter = 0;
|
|
15
|
+
function freshAppId() {
|
|
16
|
+
return `test-collapse-${++appCounter}`;
|
|
17
|
+
}
|
|
18
|
+
function renderLayout(appId) {
|
|
19
|
+
return render(DashboardLayout, {
|
|
20
|
+
props: {
|
|
21
|
+
navigation: [{ id: 'main', items: [{ id: 'home', name: 'Home', href: '/' }] }],
|
|
22
|
+
appId,
|
|
23
|
+
children: createRawSnippet(() => ({ render: () => '<p>content</p>' })),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
describe('sidebar collapse', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
localStorage.clear();
|
|
30
|
+
sidebarStore.setMobile(false);
|
|
31
|
+
});
|
|
32
|
+
it('collapses on a first session with no persisted state', async () => {
|
|
33
|
+
const appId = freshAppId();
|
|
34
|
+
renderLayout(appId);
|
|
35
|
+
expect(sidebarStore.isOpen).toBe(true);
|
|
36
|
+
await fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
|
|
37
|
+
flushSync();
|
|
38
|
+
expect(sidebarStore.isOpen).toBe(false);
|
|
39
|
+
expect(localStorage.getItem(`classic-theme-sidebar-${appId}-open`)).toBe('false');
|
|
40
|
+
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
it('keeps the collapse after a mobile round-trip in a first session', () => {
|
|
43
|
+
renderLayout(freshAppId());
|
|
44
|
+
sidebarStore.toggle();
|
|
45
|
+
flushSync();
|
|
46
|
+
sidebarStore.setMobile(true);
|
|
47
|
+
flushSync();
|
|
48
|
+
sidebarStore.setMobile(false);
|
|
49
|
+
flushSync();
|
|
50
|
+
expect(sidebarStore.isOpen).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it('marks state as persisted once the open state is written', () => {
|
|
53
|
+
sidebarStore.initialize(freshAppId());
|
|
54
|
+
expect(sidebarStore.hasPersistedState).toBe(false);
|
|
55
|
+
sidebarStore.close();
|
|
56
|
+
expect(sidebarStore.hasPersistedState).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Main content area with proper spacing
|
|
11
11
|
* - Integrated with sidebar store for state management
|
|
12
12
|
*/
|
|
13
|
-
import type
|
|
13
|
+
import { untrack, type Snippet } from 'svelte';
|
|
14
14
|
import type {
|
|
15
15
|
NavSection,
|
|
16
16
|
NavItem,
|
|
@@ -201,13 +201,18 @@
|
|
|
201
201
|
$effect(() => {
|
|
202
202
|
sidebarStore.initialize(appId);
|
|
203
203
|
|
|
204
|
-
// Only apply prop-based initial state if no persisted state exists
|
|
204
|
+
// Only apply prop-based initial state if no persisted state exists.
|
|
205
|
+
// Untracked: open()/close() read `isOpen`, and tracking it would re-run
|
|
206
|
+
// this effect on every toggle and snap the sidebar back to the default.
|
|
205
207
|
if (!sidebarStore.hasPersistedState && !isMobile) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
208
|
+
const collapsed = sidebarCollapsed;
|
|
209
|
+
untrack(() => {
|
|
210
|
+
if (collapsed) {
|
|
211
|
+
sidebarStore.close();
|
|
212
|
+
} else {
|
|
213
|
+
sidebarStore.open();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
211
216
|
}
|
|
212
217
|
});
|
|
213
218
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - Main content area with proper spacing
|
|
10
10
|
* - Integrated with sidebar store for state management
|
|
11
11
|
*/
|
|
12
|
-
import type
|
|
12
|
+
import { type Snippet } from 'svelte';
|
|
13
13
|
import type { NavSection, NavItem, User, QuickLink, BackLink, HeaderSearchConfig } from '../../types/layout.js';
|
|
14
14
|
type Breakpoint = 'sm' | 'md' | 'lg';
|
|
15
15
|
interface Props {
|
|
@@ -17,9 +17,17 @@
|
|
|
17
17
|
import Header from './Header.svelte';
|
|
18
18
|
import LogoMain from '../LogoMain.svelte';
|
|
19
19
|
|
|
20
|
+
type Breakpoint = 'sm' | 'md' | 'lg';
|
|
21
|
+
|
|
20
22
|
interface Props {
|
|
21
23
|
/** Navigation items for header */
|
|
22
24
|
navigation?: NavItem[];
|
|
25
|
+
/**
|
|
26
|
+
* Breakpoint below which the header shows the hamburger drawer instead of the
|
|
27
|
+
* inline nav. Default `md` (768px). Pick `lg` when logo + nav + headerEnd need
|
|
28
|
+
* more than ~768px: at iPad portrait widths (820px) they otherwise overlap.
|
|
29
|
+
*/
|
|
30
|
+
mobileBreakpoint?: Breakpoint;
|
|
23
31
|
/** Whether to show footer */
|
|
24
32
|
showFooter?: boolean;
|
|
25
33
|
/** Footer link sections */
|
|
@@ -55,6 +63,7 @@
|
|
|
55
63
|
|
|
56
64
|
let {
|
|
57
65
|
navigation = [],
|
|
66
|
+
mobileBreakpoint = 'md',
|
|
58
67
|
showFooter = true,
|
|
59
68
|
footerLinks = [],
|
|
60
69
|
copyright,
|
|
@@ -71,6 +80,13 @@
|
|
|
71
80
|
}: Props = $props();
|
|
72
81
|
|
|
73
82
|
let mobileMenuOpen = $state(false);
|
|
83
|
+
|
|
84
|
+
// Full class strings so Tailwind's scanner sees them.
|
|
85
|
+
const desktopOnly: Record<Breakpoint, string> = {
|
|
86
|
+
sm: 'hidden sm:contents',
|
|
87
|
+
md: 'hidden md:contents',
|
|
88
|
+
lg: 'hidden lg:contents',
|
|
89
|
+
};
|
|
74
90
|
</script>
|
|
75
91
|
|
|
76
92
|
<AppShell>
|
|
@@ -78,7 +94,7 @@
|
|
|
78
94
|
showMenuButton={navigation.length > 0}
|
|
79
95
|
menuOpen={mobileMenuOpen}
|
|
80
96
|
onMenuClick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
|
81
|
-
mobileBreakpoint
|
|
97
|
+
{mobileBreakpoint}
|
|
82
98
|
backdropBlur
|
|
83
99
|
elevated
|
|
84
100
|
maxWidth="7xl"
|
|
@@ -112,9 +128,7 @@
|
|
|
112
128
|
class={cn(
|
|
113
129
|
// Touch target: p-3 around the 20px line box makes the anchor's own
|
|
114
130
|
// border box 44px tall, and -m-3 cancels it in layout so the nav's
|
|
115
|
-
// gap-6 is unchanged (adjacent boxes meet without overlapping).
|
|
116
|
-
// box rather than an ::after overlay: iPadOS trackpad hit-testing
|
|
117
|
-
// landed below the text with the pseudo-element, but honours padding.
|
|
131
|
+
// gap-6 is unchanged (adjacent boxes meet without overlapping).
|
|
118
132
|
'-m-3 flex items-center p-3 text-sm font-bold uppercase hover:text-primary',
|
|
119
133
|
// iPadOS reports hover:none even with a trackpad attached, so the preset's
|
|
120
134
|
// hoverOnlyWhenSupported gating means hover: never fires there. any-pointer:fine
|
|
@@ -134,7 +148,7 @@
|
|
|
134
148
|
{#snippet end()}
|
|
135
149
|
{#if headerEnd && mobileNavEnd}
|
|
136
150
|
<!-- Desktop only: the mobile drawer carries mobileNavEnd instead -->
|
|
137
|
-
<div class=
|
|
151
|
+
<div class={desktopOnly[mobileBreakpoint]}>
|
|
138
152
|
{@render headerEnd()}
|
|
139
153
|
</div>
|
|
140
154
|
{:else if headerEnd}
|
|
@@ -10,9 +10,16 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Snippet } from 'svelte';
|
|
12
12
|
import type { NavItem, NavSection, HeaderSearchConfig } from '../../types/layout.js';
|
|
13
|
+
type Breakpoint = 'sm' | 'md' | 'lg';
|
|
13
14
|
interface Props {
|
|
14
15
|
/** Navigation items for header */
|
|
15
16
|
navigation?: NavItem[];
|
|
17
|
+
/**
|
|
18
|
+
* Breakpoint below which the header shows the hamburger drawer instead of the
|
|
19
|
+
* inline nav. Default `md` (768px). Pick `lg` when logo + nav + headerEnd need
|
|
20
|
+
* more than ~768px: at iPad portrait widths (820px) they otherwise overlap.
|
|
21
|
+
*/
|
|
22
|
+
mobileBreakpoint?: Breakpoint;
|
|
16
23
|
/** Whether to show footer */
|
|
17
24
|
showFooter?: boolean;
|
|
18
25
|
/** Footer link sections */
|
|
@@ -18,7 +18,8 @@ declare class SidebarStore {
|
|
|
18
18
|
*/
|
|
19
19
|
get initialized(): boolean;
|
|
20
20
|
/**
|
|
21
|
-
* Whether
|
|
21
|
+
* Whether a sidebar open state exists in localStorage (loaded at
|
|
22
|
+
* initialize, or written since)
|
|
22
23
|
*/
|
|
23
24
|
get hasPersistedState(): boolean;
|
|
24
25
|
/**
|
|
@@ -45,7 +45,8 @@ class SidebarStore {
|
|
|
45
45
|
return __classPrivateFieldGet(this, _SidebarStore_initialized, "f");
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
|
-
* Whether
|
|
48
|
+
* Whether a sidebar open state exists in localStorage (loaded at
|
|
49
|
+
* initialize, or written since)
|
|
49
50
|
*/
|
|
50
51
|
get hasPersistedState() {
|
|
51
52
|
return __classPrivateFieldGet(this, _SidebarStore_loadedFromStorage, "f");
|
|
@@ -340,6 +341,9 @@ _SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new
|
|
|
340
341
|
return;
|
|
341
342
|
try {
|
|
342
343
|
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'open'), String(this.isOpen));
|
|
344
|
+
// The key now exists: a caller that applies defaults only when nothing
|
|
345
|
+
// is persisted must stop doing so, or it undoes the user's toggle.
|
|
346
|
+
__classPrivateFieldSet(this, _SidebarStore_loadedFromStorage, true, "f");
|
|
343
347
|
}
|
|
344
348
|
catch {
|
|
345
349
|
// localStorage not available
|