@classic-homes/theme-svelte 1.15.2 → 1.16.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.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * PublicLayout mobileNavStyle="drawer": the hamburger opens the Sidebar drawer
3
+ * over the page instead of expanding a dropdown inside the header (which pushed
4
+ * the page down and, once scrolled, sat on the header's translucent bar).
5
+ */
6
+ import { describe, it, expect, vi, afterEach } from 'vitest';
7
+ import { mount, unmount, flushSync, createRawSnippet } from 'svelte';
8
+ import PublicLayout from '../lib/components/layout/PublicLayout.svelte';
9
+ const children = createRawSnippet(() => ({ render: () => '<p>Page</p>' }));
10
+ const navigation = [
11
+ { id: 'inventory', name: 'Inventory', href: '/inventory', active: true },
12
+ { id: 'pricing', name: 'Pricing', href: '/pricing' },
13
+ ];
14
+ let instance = null;
15
+ let host;
16
+ function phoneWidth(matches) {
17
+ vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
18
+ matches: query.startsWith('(max-width') ? matches : false,
19
+ media: query,
20
+ onchange: null,
21
+ addEventListener: vi.fn(),
22
+ removeEventListener: vi.fn(),
23
+ addListener: vi.fn(),
24
+ removeListener: vi.fn(),
25
+ dispatchEvent: vi.fn(),
26
+ }));
27
+ }
28
+ function mountLayout(props) {
29
+ host = document.createElement('div');
30
+ document.body.appendChild(host);
31
+ instance = mount(PublicLayout, { target: host, props: { navigation, children, ...props } });
32
+ flushSync();
33
+ const hamburger = () => host.querySelector('button[aria-label="Open menu"]');
34
+ return {
35
+ open: () => {
36
+ hamburger().click();
37
+ flushSync();
38
+ },
39
+ drawer: () => host.querySelector('aside[aria-label="Main navigation"]'),
40
+ dropdown: () => host.querySelector('nav[aria-label="Mobile navigation"]'),
41
+ header: () => host.querySelector('header'),
42
+ };
43
+ }
44
+ afterEach(() => {
45
+ if (instance)
46
+ unmount(instance);
47
+ instance = null;
48
+ host?.remove();
49
+ vi.restoreAllMocks();
50
+ });
51
+ describe('PublicLayout drawer nav', () => {
52
+ it('opens the Sidebar drawer over the page on a phone', () => {
53
+ phoneWidth(true);
54
+ const layout = mountLayout({ mobileNavStyle: 'drawer' });
55
+ // Closed drawer is off-canvas (jsdom does not reflect `inert`)
56
+ expect(layout.drawer()?.className).toContain('-translate-x-full');
57
+ layout.open();
58
+ expect(layout.drawer()?.className).not.toContain('-translate-x-full');
59
+ expect(layout.drawer()?.textContent).toContain('Pricing');
60
+ // No dropdown inside the header
61
+ expect(layout.dropdown()?.textContent ?? '').not.toContain('Pricing');
62
+ // Drawer and scrim lift over the elevated header and page sheets
63
+ const layer = layout.drawer().parentElement;
64
+ expect(layer.style.getPropertyValue('--z-sidebar')).toBe('70');
65
+ expect(layer.querySelector('[aria-hidden="true"].fixed.inset-0')).not.toBeNull();
66
+ });
67
+ it('renders no drawer above the breakpoint', () => {
68
+ phoneWidth(false);
69
+ const layout = mountLayout({ mobileNavStyle: 'drawer' });
70
+ expect(layout.drawer()).toBeNull();
71
+ });
72
+ it('keeps the dropdown by default, on a solid background', () => {
73
+ phoneWidth(true);
74
+ const layout = mountLayout({});
75
+ layout.open();
76
+ expect(layout.drawer()).toBeNull();
77
+ expect(layout.dropdown()?.textContent).toContain('Pricing');
78
+ expect(layout.dropdown()?.className).toContain('bg-background');
79
+ expect(layout.header().className).toContain('z-50');
80
+ });
81
+ it('gives a parent with children a chevron menu in the header, and keeps the link', () => {
82
+ phoneWidth(false);
83
+ host = document.createElement('div');
84
+ document.body.appendChild(host);
85
+ instance = mount(PublicLayout, {
86
+ target: host,
87
+ props: {
88
+ children,
89
+ navigation: [
90
+ {
91
+ id: 'inventory',
92
+ name: 'Inventory',
93
+ href: '/inventory',
94
+ active: true,
95
+ children: [
96
+ { id: 'inventory-community', name: 'Community', href: '/inventory?view=community' },
97
+ {
98
+ id: 'inventory-collection',
99
+ name: 'Collection',
100
+ href: '/inventory?view=collection',
101
+ },
102
+ ],
103
+ },
104
+ { id: 'pricing', name: 'Pricing', href: '/pricing' },
105
+ ],
106
+ },
107
+ });
108
+ flushSync();
109
+ const link = host.querySelector('header a[href="/inventory"]');
110
+ expect(link?.getAttribute('aria-current')).toBe('page');
111
+ const trigger = host.querySelector('header button[aria-label="Inventory options"]');
112
+ expect(trigger).not.toBeNull();
113
+ // Only parents with children get one
114
+ expect(host.querySelector('header button[aria-label="Pricing options"]')).toBeNull();
115
+ });
116
+ });
@@ -280,6 +280,16 @@
280
280
  */
281
281
  const isMobileCard = $derived(variant === 'card' && isMobile);
282
282
 
283
+ /**
284
+ * Bottom padding of the phone card: the home-indicator inset less the
285
+ * header's own 12px bottom padding and 8px more. The whole 34px iPhone inset
286
+ * stood the collapsed card 106px tall; this keeps the title about 26px above
287
+ * the screen edge, clear of the indicator, at 86px. The strip below the
288
+ * header is padding, so a swipe that starts there never reaches the header's
289
+ * pointer handler.
290
+ */
291
+ const CARD_SAFE_AREA_PAD = 'max(0px, calc(env(safe-area-inset-bottom, 0px) - 20px))';
292
+
283
293
  // Whether the panel is currently presented as a modal dialog (detached, or an
284
294
  // expanded mobile card). In that state we trap focus for a11y parity with React.
285
295
  // A desktop card is a non-modal floating sheet with no scrim, so it must not
@@ -1193,15 +1203,15 @@
1193
1203
  // Full-width phone sheet at its expanded height; `height` above is how
1194
1204
  // much of it shows. The part below the fold is slid off-screen with
1195
1205
  // `translate` (composited, and it composes with the mount keyframe's
1196
- // `transform`). The safe-area inset is added to the sheet and padded
1206
+ // `transform`). CARD_SAFE_AREA_PAD is added to the sheet and padded
1197
1207
  // out, so the collapsed header sits above the home indicator instead
1198
1208
  // of inside the strip iOS claims for its swipe-to-leave gesture.
1199
1209
  return {
1200
1210
  bottom: '0',
1201
1211
  left: `${defaultLeftOffset}px`,
1202
1212
  width: `${defaultCardWidth}px`,
1203
- height: `calc(${cardExpandedHeight}px + env(safe-area-inset-bottom, 0px))`,
1204
- 'padding-bottom': 'env(safe-area-inset-bottom, 0px)',
1213
+ height: `calc(${cardExpandedHeight}px + ${CARD_SAFE_AREA_PAD})`,
1214
+ 'padding-bottom': CARD_SAFE_AREA_PAD,
1205
1215
  translate: `0 ${Math.max(0, cardExpandedHeight - height)}px`,
1206
1216
  };
1207
1217
  }
@@ -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 */
@@ -458,11 +458,16 @@
458
458
  }
459
459
  </script>
460
460
 
461
- <!-- Mobile Overlay: z-40 positions below sidebar (z-50) but above main content.
461
+ <!-- Mobile Overlay: 10 below the sidebar (--z-sidebar, default 50, so z-40) but
462
+ above main content; a host that lifts --z-sidebar lifts the scrim with it.
462
463
  Not focusable: the drawer traps focus and handles Escape itself. -->
463
464
  {#if isMobile && mobileOpen}
464
465
  <!-- 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>
466
+ <div
467
+ class="fixed inset-0 z-[calc(var(--z-sidebar,50)_-_10)] bg-black/50"
468
+ onclick={() => onClose?.()}
469
+ aria-hidden="true"
470
+ ></div>
466
471
  {/if}
467
472
 
468
473
  <!-- Sidebar: z-50 positions above overlay and content, below modals (z-100+) -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.15.2",
3
+ "version": "1.16.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",
@@ -30,6 +30,7 @@
30
30
  "clean": "node ../../scripts/rm.mjs dist",
31
31
  "typecheck": "svelte-check --tsconfig ./tsconfig.json",
32
32
  "test": "vitest run",
33
+ "test:shard": "vitest run --shard=$VITEST_SHARD",
33
34
  "test:watch": "vitest",
34
35
  "test:a11y": "vitest run src/__tests__/a11y/"
35
36
  },
@@ -72,6 +73,7 @@
72
73
  "zod": "^3.23.0"
73
74
  },
74
75
  "devDependencies": {
76
+ "@classic-homes/notifications": "*",
75
77
  "@sveltejs/package": "^2.3.0",
76
78
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
77
79
  "@testing-library/jest-dom": "^6.6.0",