@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.
- package/dist/__tests__/a11y/PublicLayout.a11y.test.js +128 -0
- package/dist/lib/components/layout/Footer.svelte +9 -4
- package/dist/lib/components/layout/Header.svelte +6 -2
- package/dist/lib/components/layout/PublicLayout.svelte +37 -5
- package/dist/lib/components/layout/PublicLayout.svelte.d.ts +9 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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-
|
|
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 */
|