@classic-homes/theme-svelte 1.7.0 → 1.7.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.
- package/dist/__tests__/a11y/PublicLayout.a11y.test.js +90 -0
- package/dist/lib/components/layout/Footer.svelte +9 -4
- package/dist/lib/components/layout/Header.svelte +7 -3
- package/dist/lib/components/layout/PublicLayout.svelte +16 -3
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +5 -2
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
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() {
|
|
27
|
+
return render(PublicLayout, {
|
|
28
|
+
props: { navigation, footerLinks, children: textSnippet('Page content') },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* jsdom has no layout engine, so getBoundingClientRect() returns zeros and target
|
|
33
|
+
* size cannot be measured here. These assert the classes that produce a 44x44px
|
|
34
|
+
* target (WCAG 2.5.5); the geometry itself is verified in a real browser.
|
|
35
|
+
*/
|
|
36
|
+
describe('PublicLayout accessibility', () => {
|
|
37
|
+
it('should have no axe violations', async () => {
|
|
38
|
+
const { container } = renderLayout();
|
|
39
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
40
|
+
});
|
|
41
|
+
it('should expand desktop nav links to a 44px touch target', () => {
|
|
42
|
+
renderLayout();
|
|
43
|
+
const nav = screen.getByRole('navigation', { name: 'Main navigation' });
|
|
44
|
+
const links = [...nav.querySelectorAll(':scope > a')];
|
|
45
|
+
expect(links).toHaveLength(navigation.length);
|
|
46
|
+
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');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
it('should give the logo link a 44px-tall touch target', () => {
|
|
54
|
+
renderLayout();
|
|
55
|
+
const logo = screen.getByRole('banner').querySelector('a[href="/"]');
|
|
56
|
+
expect(logo?.className).toContain('relative');
|
|
57
|
+
expect(logo?.className).toContain('after:h-11');
|
|
58
|
+
});
|
|
59
|
+
it('should give mobile drawer links a 44px touch target', async () => {
|
|
60
|
+
renderLayout();
|
|
61
|
+
// The drawer is only in the DOM while the menu is open.
|
|
62
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
|
|
63
|
+
const drawerNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
|
|
64
|
+
for (const link of drawerNav.querySelectorAll('a')) {
|
|
65
|
+
expect(link.className).toContain('py-3');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
it('should give footer links a 44px-tall full-width touch target', () => {
|
|
69
|
+
renderLayout();
|
|
70
|
+
const footerNav = screen.getByRole('navigation', { name: 'Footer navigation' });
|
|
71
|
+
for (const link of footerNav.querySelectorAll('a')) {
|
|
72
|
+
// block + py-3 = 12 + 20 + 12; the list carries no space-y, so rows never overlap
|
|
73
|
+
expect(link.className).toContain('block');
|
|
74
|
+
expect(link.className).toContain('py-3');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it('should cue hover for a trackpad pointer that reports hover: none', () => {
|
|
78
|
+
renderLayout();
|
|
79
|
+
const nav = screen.getByRole('navigation', { name: 'Main navigation' });
|
|
80
|
+
const footerNav = screen.getByRole('navigation', { name: 'Footer navigation' });
|
|
81
|
+
// hoverOnlyWhenSupported gates hover: behind (hover: hover) and (pointer: fine),
|
|
82
|
+
// which iPadOS never matches even with a trackpad attached.
|
|
83
|
+
for (const link of [
|
|
84
|
+
...nav.querySelectorAll(':scope > a'),
|
|
85
|
+
...footerNav.querySelectorAll('a'),
|
|
86
|
+
]) {
|
|
87
|
+
expect(link.className).toContain('[@media(any-pointer:fine)]:[&:hover]:');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -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}
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
<div class={cn('h-16', strongBorder ? 'border-b-2 border-black' : 'border-b border-black')}>
|
|
179
179
|
<div
|
|
180
180
|
class={cn(
|
|
181
|
-
'flex h-full items-center justify-between px-2 sm:px-4 lg:px-4',
|
|
181
|
+
'flex h-full items-center justify-between gap-4 sm:gap-6 px-2 sm:px-4 lg:px-4',
|
|
182
182
|
maxWidth !== 'none' && 'mx-auto',
|
|
183
183
|
maxWidthClasses[maxWidth]
|
|
184
184
|
)}
|
|
@@ -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?.()}
|
|
@@ -77,7 +77,12 @@
|
|
|
77
77
|
search={headerSearch}
|
|
78
78
|
>
|
|
79
79
|
{#snippet start()}
|
|
80
|
-
|
|
80
|
+
<!-- Touch target expansion (invisible, guarantees a 44px-tall clickable area) -->
|
|
81
|
+
<a
|
|
82
|
+
href={logoHref}
|
|
83
|
+
class="relative flex items-center after:absolute after:inset-x-0 after:top-1/2 after:h-11
|
|
84
|
+
after:-translate-y-1/2 after:content-['']"
|
|
85
|
+
>
|
|
81
86
|
{#if logo}
|
|
82
87
|
{@render logo()}
|
|
83
88
|
{:else}
|
|
@@ -97,7 +102,15 @@
|
|
|
97
102
|
<a
|
|
98
103
|
href={item.href}
|
|
99
104
|
class={cn(
|
|
100
|
-
'text-sm font-bold uppercase hover:text-primary',
|
|
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-['']",
|
|
110
|
+
// iPadOS reports hover:none even with a trackpad attached, so the preset's
|
|
111
|
+
// hoverOnlyWhenSupported gating means hover: never fires there. any-pointer:fine
|
|
112
|
+
// matches when any attached pointer is precise, giving trackpad users a cue.
|
|
113
|
+
'[@media(any-pointer:fine)]:[&:hover]:text-primary',
|
|
101
114
|
item.active ? 'text-primary' : 'text-muted-foreground'
|
|
102
115
|
)}
|
|
103
116
|
target={item.external ? '_blank' : undefined}
|
|
@@ -122,7 +135,7 @@
|
|
|
122
135
|
<a
|
|
123
136
|
href={item.href}
|
|
124
137
|
class={cn(
|
|
125
|
-
'block rounded-md px-3 py-
|
|
138
|
+
'block rounded-md px-3 py-3 text-sm font-bold uppercase',
|
|
126
139
|
item.active
|
|
127
140
|
? 'bg-primary/10 text-primary'
|
|
128
141
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
|
@@ -190,8 +190,11 @@
|
|
|
190
190
|
class={cn(
|
|
191
191
|
'flex items-center justify-center p-1 rounded shrink-0',
|
|
192
192
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
|
193
|
-
// Show on hover for regular items, always show in pinned section
|
|
194
|
-
|
|
193
|
+
// Show on hover for regular items, always show in pinned section.
|
|
194
|
+
// Touch devices never fire hover (hoverOnlyWhenSupported), so keep it visible there.
|
|
195
|
+
isUnpin
|
|
196
|
+
? 'opacity-100'
|
|
197
|
+
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-60',
|
|
195
198
|
// Light variant: white text when row is hovered (teal bg), red/primary text when active
|
|
196
199
|
isLight && !item.active && 'text-muted-foreground group-hover:text-white hover:bg-white/20',
|
|
197
200
|
isLight && item.active && 'text-primary hover:bg-primary/20',
|