@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 inset = 44px, with no layout shift
48
- expect(link.className).toContain('relative');
49
- expect(link.className).toContain('after:absolute');
50
- expect(link.className).toContain('after:-inset-3');
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="md"
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
- 'relative text-sm font-bold uppercase hover:text-primary',
106
- // Touch target expansion (invisible, extends clickable area to 44x44px).
107
- // -inset-3 exactly fills the nav's gap-6, so the space between links is
108
- // no longer dead: adjacent expansions meet without overlapping.
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="space-y-2 border-b border-black px-4 py-4">
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 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.7.2",
3
+ "version": "1.8.2",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",