@classic-homes/theme-svelte 1.7.2 → 1.8.2
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.
|
@@ -23,11 +23,20 @@ const footerLinks = [
|
|
|
23
23
|
],
|
|
24
24
|
},
|
|
25
25
|
];
|
|
26
|
-
function renderLayout() {
|
|
26
|
+
function renderLayout(extra = {}) {
|
|
27
27
|
return render(PublicLayout, {
|
|
28
|
-
props: { navigation, footerLinks, children: textSnippet('Page content') },
|
|
28
|
+
props: { navigation, footerLinks, children: textSnippet('Page content'), ...extra },
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
/** A drawer CTA that calls the `close` argument PublicLayout hands to mobileNavEnd. */
|
|
32
|
+
function ctaSnippet(label) {
|
|
33
|
+
return createRawSnippet((args) => ({
|
|
34
|
+
render: () => `<button type="button">${label}</button>`,
|
|
35
|
+
setup: (el) => {
|
|
36
|
+
el.addEventListener('click', () => args().close());
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
31
40
|
/**
|
|
32
41
|
* jsdom has no layout engine, so getBoundingClientRect() returns zeros and target
|
|
33
42
|
* size cannot be measured here. These assert the classes that produce a 44x44px
|
|
@@ -44,10 +53,11 @@ describe('PublicLayout accessibility', () => {
|
|
|
44
53
|
const links = [...nav.querySelectorAll(':scope > a')];
|
|
45
54
|
expect(links).toHaveLength(navigation.length);
|
|
46
55
|
for (const link of links) {
|
|
47
|
-
// 20px text line box + 2 x 12px
|
|
48
|
-
|
|
49
|
-
expect(link.className).toContain('
|
|
50
|
-
expect(link.className).toContain('
|
|
56
|
+
// 20px text line box + 2 x 12px padding = 44px border box; -m-3 cancels
|
|
57
|
+
// it in layout so the nav's gap-6 spacing is unchanged.
|
|
58
|
+
expect(link.className).toContain('p-3');
|
|
59
|
+
expect(link.className).toContain('-m-3');
|
|
60
|
+
expect(link.className).not.toContain('after:');
|
|
51
61
|
}
|
|
52
62
|
});
|
|
53
63
|
it('should give the logo link a 44px-tall touch target', () => {
|
|
@@ -74,6 +84,45 @@ describe('PublicLayout accessibility', () => {
|
|
|
74
84
|
expect(link.className).toContain('py-3');
|
|
75
85
|
}
|
|
76
86
|
});
|
|
87
|
+
it('should render headerEnd unwrapped when there is no mobileNavEnd', () => {
|
|
88
|
+
renderLayout({ headerEnd: textSnippet('Service Request') });
|
|
89
|
+
const cta = screen.getByText('Service Request');
|
|
90
|
+
expect(cta.parentElement?.className).not.toContain('hidden');
|
|
91
|
+
});
|
|
92
|
+
it('should hide headerEnd below the mobile breakpoint when mobileNavEnd is given', () => {
|
|
93
|
+
renderLayout({
|
|
94
|
+
headerEnd: textSnippet('Service Request'),
|
|
95
|
+
mobileNavEnd: ctaSnippet('Drawer CTA'),
|
|
96
|
+
});
|
|
97
|
+
const cta = screen.getByText('Service Request');
|
|
98
|
+
expect(cta.parentElement?.className).toContain('hidden');
|
|
99
|
+
expect(cta.parentElement?.className).toContain('md:contents');
|
|
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
|
+
});
|
|
113
|
+
it('should render mobileNavEnd in the drawer and close it via the close argument', async () => {
|
|
114
|
+
renderLayout({
|
|
115
|
+
headerEnd: textSnippet('Service Request'),
|
|
116
|
+
mobileNavEnd: ctaSnippet('Drawer CTA'),
|
|
117
|
+
});
|
|
118
|
+
expect(screen.queryByRole('button', { name: 'Drawer CTA' })).toBeNull();
|
|
119
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
|
|
120
|
+
const drawerNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
|
|
121
|
+
const cta = screen.getByRole('button', { name: 'Drawer CTA' });
|
|
122
|
+
expect(drawerNav.contains(cta)).toBe(true);
|
|
123
|
+
await userEvent.click(cta);
|
|
124
|
+
expect(screen.queryByRole('navigation', { name: 'Mobile navigation' })).toBeNull();
|
|
125
|
+
});
|
|
77
126
|
it('should cue hover for a trackpad pointer that reports hover: none', () => {
|
|
78
127
|
renderLayout();
|
|
79
128
|
const nav = screen.getByRole('navigation', { name: 'Main navigation' });
|
|
@@ -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 */
|
|
@@ -34,6 +42,13 @@
|
|
|
34
42
|
logoEnvironment?: 'local' | 'dev' | 'demo';
|
|
35
43
|
/** Custom header end content */
|
|
36
44
|
headerEnd?: Snippet;
|
|
45
|
+
/**
|
|
46
|
+
* Content rendered at the bottom of the mobile drawer (e.g. a CTA button).
|
|
47
|
+
* When provided, `headerEnd` is hidden below the mobile breakpoint so a
|
|
48
|
+
* wide button no longer competes with the logo on phone widths. Receives
|
|
49
|
+
* `close` so the drawer can be dismissed after navigation.
|
|
50
|
+
*/
|
|
51
|
+
mobileNavEnd?: Snippet<[{ close: () => void }]>;
|
|
37
52
|
/** Use strong accent border on footer (10px top border) */
|
|
38
53
|
strongFooterBorder?: boolean;
|
|
39
54
|
/** Use dark footer variant */
|
|
@@ -48,6 +63,7 @@
|
|
|
48
63
|
|
|
49
64
|
let {
|
|
50
65
|
navigation = [],
|
|
66
|
+
mobileBreakpoint = 'md',
|
|
51
67
|
showFooter = true,
|
|
52
68
|
footerLinks = [],
|
|
53
69
|
copyright,
|
|
@@ -55,6 +71,7 @@
|
|
|
55
71
|
logoSubtitle,
|
|
56
72
|
logoEnvironment,
|
|
57
73
|
headerEnd,
|
|
74
|
+
mobileNavEnd,
|
|
58
75
|
strongFooterBorder = false,
|
|
59
76
|
darkFooter = false,
|
|
60
77
|
headerSearch,
|
|
@@ -63,6 +80,13 @@
|
|
|
63
80
|
}: Props = $props();
|
|
64
81
|
|
|
65
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
|
+
};
|
|
66
90
|
</script>
|
|
67
91
|
|
|
68
92
|
<AppShell>
|
|
@@ -70,7 +94,7 @@
|
|
|
70
94
|
showMenuButton={navigation.length > 0}
|
|
71
95
|
menuOpen={mobileMenuOpen}
|
|
72
96
|
onMenuClick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
|
73
|
-
mobileBreakpoint
|
|
97
|
+
{mobileBreakpoint}
|
|
74
98
|
backdropBlur
|
|
75
99
|
elevated
|
|
76
100
|
maxWidth="7xl"
|
|
@@ -102,11 +126,10 @@
|
|
|
102
126
|
<a
|
|
103
127
|
href={item.href}
|
|
104
128
|
class={cn(
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
"after:absolute after:-inset-3 after:content-['']",
|
|
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',
|
|
110
133
|
// iPadOS reports hover:none even with a trackpad attached, so the preset's
|
|
111
134
|
// hoverOnlyWhenSupported gating means hover: never fires there. any-pointer:fine
|
|
112
135
|
// matches when any attached pointer is precise, giving trackpad users a cue.
|
|
@@ -123,13 +146,18 @@
|
|
|
123
146
|
{/snippet}
|
|
124
147
|
|
|
125
148
|
{#snippet end()}
|
|
126
|
-
{#if headerEnd}
|
|
149
|
+
{#if headerEnd && mobileNavEnd}
|
|
150
|
+
<!-- Desktop only: the mobile drawer carries mobileNavEnd instead -->
|
|
151
|
+
<div class={desktopOnly[mobileBreakpoint]}>
|
|
152
|
+
{@render headerEnd()}
|
|
153
|
+
</div>
|
|
154
|
+
{:else if headerEnd}
|
|
127
155
|
{@render headerEnd()}
|
|
128
156
|
{/if}
|
|
129
157
|
{/snippet}
|
|
130
158
|
|
|
131
159
|
{#snippet mobileNav()}
|
|
132
|
-
<ul class=
|
|
160
|
+
<ul class={cn('space-y-2 px-4 py-4', !mobileNavEnd && 'border-b border-black')}>
|
|
133
161
|
{#each navigation as item}
|
|
134
162
|
<li>
|
|
135
163
|
<a
|
|
@@ -150,6 +178,11 @@
|
|
|
150
178
|
</li>
|
|
151
179
|
{/each}
|
|
152
180
|
</ul>
|
|
181
|
+
{#if mobileNavEnd}
|
|
182
|
+
<div class="border-b border-black px-4 pb-4">
|
|
183
|
+
{@render mobileNavEnd({ close: () => (mobileMenuOpen = false) })}
|
|
184
|
+
</div>
|
|
185
|
+
{/if}
|
|
153
186
|
{/snippet}
|
|
154
187
|
</Header>
|
|
155
188
|
|
|
@@ -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 */
|
|
@@ -27,6 +34,15 @@ interface Props {
|
|
|
27
34
|
logoEnvironment?: 'local' | 'dev' | 'demo';
|
|
28
35
|
/** Custom header end content */
|
|
29
36
|
headerEnd?: Snippet;
|
|
37
|
+
/**
|
|
38
|
+
* Content rendered at the bottom of the mobile drawer (e.g. a CTA button).
|
|
39
|
+
* When provided, `headerEnd` is hidden below the mobile breakpoint so a
|
|
40
|
+
* wide button no longer competes with the logo on phone widths. Receives
|
|
41
|
+
* `close` so the drawer can be dismissed after navigation.
|
|
42
|
+
*/
|
|
43
|
+
mobileNavEnd?: Snippet<[{
|
|
44
|
+
close: () => void;
|
|
45
|
+
}]>;
|
|
30
46
|
/** Use strong accent border on footer (10px top border) */
|
|
31
47
|
strongFooterBorder?: boolean;
|
|
32
48
|
/** Use dark footer variant */
|