@classic-homes/theme-svelte 1.7.1 → 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.
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/svelte';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { axe } from 'vitest-axe';
5
+ import { createRawSnippet } from 'svelte';
6
+ import PublicLayout from '../../lib/components/layout/PublicLayout.svelte';
7
+ function textSnippet(text) {
8
+ return createRawSnippet(() => ({
9
+ render: () => `<span>${text}</span>`,
10
+ }));
11
+ }
12
+ const navigation = [
13
+ { name: 'Orientation', href: '/orientation' },
14
+ { name: 'Home Info', href: '/home-information-sheet' },
15
+ { name: 'Warranty Transfer', href: '/warranty-transfer', active: true },
16
+ ];
17
+ const footerLinks = [
18
+ {
19
+ title: 'Resources',
20
+ items: [
21
+ { name: 'Documentation', href: '/docs' },
22
+ { name: 'GitHub', href: 'https://github.com', external: true },
23
+ ],
24
+ },
25
+ ];
26
+ function renderLayout(extra = {}) {
27
+ return render(PublicLayout, {
28
+ props: { navigation, footerLinks, children: textSnippet('Page content'), ...extra },
29
+ });
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
+ }
40
+ /**
41
+ * jsdom has no layout engine, so getBoundingClientRect() returns zeros and target
42
+ * size cannot be measured here. These assert the classes that produce a 44x44px
43
+ * target (WCAG 2.5.5); the geometry itself is verified in a real browser.
44
+ */
45
+ describe('PublicLayout accessibility', () => {
46
+ it('should have no axe violations', async () => {
47
+ const { container } = renderLayout();
48
+ expect(await axe(container)).toHaveNoViolations();
49
+ });
50
+ it('should expand desktop nav links to a 44px touch target', () => {
51
+ renderLayout();
52
+ const nav = screen.getByRole('navigation', { name: 'Main navigation' });
53
+ const links = [...nav.querySelectorAll(':scope > a')];
54
+ expect(links).toHaveLength(navigation.length);
55
+ for (const link of links) {
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:');
62
+ }
63
+ });
64
+ it('should give the logo link a 44px-tall touch target', () => {
65
+ renderLayout();
66
+ const logo = screen.getByRole('banner').querySelector('a[href="/"]');
67
+ expect(logo?.className).toContain('relative');
68
+ expect(logo?.className).toContain('after:h-11');
69
+ });
70
+ it('should give mobile drawer links a 44px touch target', async () => {
71
+ renderLayout();
72
+ // The drawer is only in the DOM while the menu is open.
73
+ await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
74
+ const drawerNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
75
+ for (const link of drawerNav.querySelectorAll('a')) {
76
+ expect(link.className).toContain('py-3');
77
+ }
78
+ });
79
+ it('should give footer links a 44px-tall full-width touch target', () => {
80
+ renderLayout();
81
+ const footerNav = screen.getByRole('navigation', { name: 'Footer navigation' });
82
+ for (const link of footerNav.querySelectorAll('a')) {
83
+ // block + py-3 = 12 + 20 + 12; the list carries no space-y, so rows never overlap
84
+ expect(link.className).toContain('block');
85
+ expect(link.className).toContain('py-3');
86
+ }
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
+ });
115
+ it('should cue hover for a trackpad pointer that reports hover: none', () => {
116
+ renderLayout();
117
+ const nav = screen.getByRole('navigation', { name: 'Main navigation' });
118
+ const footerNav = screen.getByRole('navigation', { name: 'Footer navigation' });
119
+ // hoverOnlyWhenSupported gates hover: behind (hover: hover) and (pointer: fine),
120
+ // which iPadOS never matches even with a trackpad attached.
121
+ for (const link of [
122
+ ...nav.querySelectorAll(':scope > a'),
123
+ ...footerNav.querySelectorAll('a'),
124
+ ]) {
125
+ expect(link.className).toContain('[@media(any-pointer:fine)]:[&:hover]:');
126
+ }
127
+ });
128
+ });
@@ -71,16 +71,21 @@
71
71
  {section.title}
72
72
  </h2>
73
73
  {/if}
74
- <ul class="mt-4 space-y-2">
74
+ <ul class="mt-4">
75
75
  {#each section.items as item}
76
76
  <li>
77
77
  <a
78
78
  href={item.href}
79
79
  class={cn(
80
- 'text-sm',
80
+ // block + py-3 gives a full-width 44px-tall target (WCAG 2.5.5);
81
+ // the list has no space-y so rows sit flush and never overlap.
82
+ 'block py-3 text-sm',
83
+ // iPadOS reports hover:none even with a trackpad attached, so the
84
+ // preset's hoverOnlyWhenSupported gating means hover: never fires
85
+ // there. any-pointer:fine matches when a precise pointer exists.
81
86
  isDark
82
- ? 'text-white hover:text-primary'
83
- : 'text-muted-foreground hover:text-foreground'
87
+ ? 'text-white hover:text-primary [@media(any-pointer:fine)]:[&:hover]:text-primary'
88
+ : 'text-muted-foreground hover:text-foreground [@media(any-pointer:fine)]:[&:hover]:text-foreground'
84
89
  )}
85
90
  target={item.external ? '_blank' : undefined}
86
91
  rel={item.external ? 'noopener noreferrer' : undefined}
@@ -188,7 +188,9 @@
188
188
  {#if showMenuButton}
189
189
  <button
190
190
  class={cn(
191
- 'shrink-0 rounded-md p-2 hover:bg-accent hover:text-accent-foreground',
191
+ 'relative shrink-0 rounded-md p-2 hover:bg-accent hover:text-accent-foreground',
192
+ // Touch target expansion (invisible, extends clickable area to 44x44px)
193
+ "after:absolute after:-inset-0.5 after:content-['']",
192
194
  bp.menuButtonHide
193
195
  )}
194
196
  data-sidebar-trigger
@@ -222,7 +224,9 @@
222
224
  {#if showCollapseButton}
223
225
  <button
224
226
  class={cn(
225
- 'rounded-md p-2 hover:bg-accent hover:text-accent-foreground',
227
+ 'relative rounded-md p-2 hover:bg-accent hover:text-accent-foreground',
228
+ // Touch target expansion (invisible, extends clickable area to 44x44px)
229
+ "after:absolute after:-inset-1 after:content-['']",
226
230
  bp.collapseButtonShow
227
231
  )}
228
232
  onclick={() => onCollapseClick?.()}
@@ -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,
@@ -77,7 +85,12 @@
77
85
  search={headerSearch}
78
86
  >
79
87
  {#snippet start()}
80
- <a href={logoHref} class="flex items-center">
88
+ <!-- Touch target expansion (invisible, guarantees a 44px-tall clickable area) -->
89
+ <a
90
+ href={logoHref}
91
+ class="relative flex items-center after:absolute after:inset-x-0 after:top-1/2 after:h-11
92
+ after:-translate-y-1/2 after:content-['']"
93
+ >
81
94
  {#if logo}
82
95
  {@render logo()}
83
96
  {:else}
@@ -97,7 +110,16 @@
97
110
  <a
98
111
  href={item.href}
99
112
  class={cn(
100
- 'text-sm font-bold uppercase hover:text-primary',
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',
119
+ // iPadOS reports hover:none even with a trackpad attached, so the preset's
120
+ // hoverOnlyWhenSupported gating means hover: never fires there. any-pointer:fine
121
+ // matches when any attached pointer is precise, giving trackpad users a cue.
122
+ '[@media(any-pointer:fine)]:[&:hover]:text-primary',
101
123
  item.active ? 'text-primary' : 'text-muted-foreground'
102
124
  )}
103
125
  target={item.external ? '_blank' : undefined}
@@ -110,19 +132,24 @@
110
132
  {/snippet}
111
133
 
112
134
  {#snippet end()}
113
- {#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}
114
141
  {@render headerEnd()}
115
142
  {/if}
116
143
  {/snippet}
117
144
 
118
145
  {#snippet mobileNav()}
119
- <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')}>
120
147
  {#each navigation as item}
121
148
  <li>
122
149
  <a
123
150
  href={item.href}
124
151
  class={cn(
125
- 'block rounded-md px-3 py-2 text-sm font-bold uppercase',
152
+ 'block rounded-md px-3 py-3 text-sm font-bold uppercase',
126
153
  item.active
127
154
  ? 'bg-primary/10 text-primary'
128
155
  : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
@@ -137,6 +164,11 @@
137
164
  </li>
138
165
  {/each}
139
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}
140
172
  {/snippet}
141
173
  </Header>
142
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.1",
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",