@classic-homes/theme-svelte 1.7.2 → 1.8.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.
@@ -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,12 @@ 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. A real box, not an ::after overlay: iPadOS trackpad
58
+ // hit-testing missed the pseudo-element but honours padding.
59
+ expect(link.className).toContain('p-3');
60
+ expect(link.className).toContain('-m-3');
61
+ expect(link.className).not.toContain('after:');
51
62
  }
52
63
  });
53
64
  it('should give the logo link a 44px-tall touch target', () => {
@@ -74,6 +85,33 @@ describe('PublicLayout accessibility', () => {
74
85
  expect(link.className).toContain('py-3');
75
86
  }
76
87
  });
88
+ it('should render headerEnd unwrapped when there is no mobileNavEnd', () => {
89
+ renderLayout({ headerEnd: textSnippet('Service Request') });
90
+ const cta = screen.getByText('Service Request');
91
+ expect(cta.parentElement?.className).not.toContain('hidden');
92
+ });
93
+ it('should hide headerEnd below the mobile breakpoint when mobileNavEnd is given', () => {
94
+ renderLayout({
95
+ headerEnd: textSnippet('Service Request'),
96
+ mobileNavEnd: ctaSnippet('Drawer CTA'),
97
+ });
98
+ const cta = screen.getByText('Service Request');
99
+ expect(cta.parentElement?.className).toContain('hidden');
100
+ expect(cta.parentElement?.className).toContain('md:contents');
101
+ });
102
+ it('should render mobileNavEnd in the drawer and close it via the close argument', async () => {
103
+ renderLayout({
104
+ headerEnd: textSnippet('Service Request'),
105
+ mobileNavEnd: ctaSnippet('Drawer CTA'),
106
+ });
107
+ expect(screen.queryByRole('button', { name: 'Drawer CTA' })).toBeNull();
108
+ await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
109
+ const drawerNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
110
+ const cta = screen.getByRole('button', { name: 'Drawer CTA' });
111
+ expect(drawerNav.contains(cta)).toBe(true);
112
+ await userEvent.click(cta);
113
+ expect(screen.queryByRole('navigation', { name: 'Mobile navigation' })).toBeNull();
114
+ });
77
115
  it('should cue hover for a trackpad pointer that reports hover: none', () => {
78
116
  renderLayout();
79
117
  const nav = screen.getByRole('navigation', { name: 'Main navigation' });
@@ -34,6 +34,13 @@
34
34
  logoEnvironment?: 'local' | 'dev' | 'demo';
35
35
  /** Custom header end content */
36
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<[{ close: () => void }]>;
37
44
  /** Use strong accent border on footer (10px top border) */
38
45
  strongFooterBorder?: boolean;
39
46
  /** Use dark footer variant */
@@ -55,6 +62,7 @@
55
62
  logoSubtitle,
56
63
  logoEnvironment,
57
64
  headerEnd,
65
+ mobileNavEnd,
58
66
  strongFooterBorder = false,
59
67
  darkFooter = false,
60
68
  headerSearch,
@@ -102,11 +110,12 @@
102
110
  <a
103
111
  href={item.href}
104
112
  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-['']",
113
+ // Touch target: p-3 around the 20px line box makes the anchor's own
114
+ // 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). A real
116
+ // box rather than an ::after overlay: iPadOS trackpad hit-testing
117
+ // landed below the text with the pseudo-element, but honours padding.
118
+ '-m-3 flex items-center p-3 text-sm font-bold uppercase hover:text-primary',
110
119
  // iPadOS reports hover:none even with a trackpad attached, so the preset's
111
120
  // hoverOnlyWhenSupported gating means hover: never fires there. any-pointer:fine
112
121
  // matches when any attached pointer is precise, giving trackpad users a cue.
@@ -123,13 +132,18 @@
123
132
  {/snippet}
124
133
 
125
134
  {#snippet end()}
126
- {#if headerEnd}
135
+ {#if headerEnd && mobileNavEnd}
136
+ <!-- Desktop only: the mobile drawer carries mobileNavEnd instead -->
137
+ <div class="hidden md:contents">
138
+ {@render headerEnd()}
139
+ </div>
140
+ {:else if headerEnd}
127
141
  {@render headerEnd()}
128
142
  {/if}
129
143
  {/snippet}
130
144
 
131
145
  {#snippet mobileNav()}
132
- <ul class="space-y-2 border-b border-black px-4 py-4">
146
+ <ul class={cn('space-y-2 px-4 py-4', !mobileNavEnd && 'border-b border-black')}>
133
147
  {#each navigation as item}
134
148
  <li>
135
149
  <a
@@ -150,6 +164,11 @@
150
164
  </li>
151
165
  {/each}
152
166
  </ul>
167
+ {#if mobileNavEnd}
168
+ <div class="border-b border-black px-4 pb-4">
169
+ {@render mobileNavEnd({ close: () => (mobileMenuOpen = false) })}
170
+ </div>
171
+ {/if}
153
172
  {/snippet}
154
173
  </Header>
155
174
 
@@ -27,6 +27,15 @@ interface Props {
27
27
  logoEnvironment?: 'local' | 'dev' | 'demo';
28
28
  /** Custom header end content */
29
29
  headerEnd?: Snippet;
30
+ /**
31
+ * Content rendered at the bottom of the mobile drawer (e.g. a CTA button).
32
+ * When provided, `headerEnd` is hidden below the mobile breakpoint so a
33
+ * wide button no longer competes with the logo on phone widths. Receives
34
+ * `close` so the drawer can be dismissed after navigation.
35
+ */
36
+ mobileNavEnd?: Snippet<[{
37
+ close: () => void;
38
+ }]>;
30
39
  /** Use strong accent border on footer (10px top border) */
31
40
  strongFooterBorder?: boolean;
32
41
  /** 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.1",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",