@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.
@@ -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 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}
@@ -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
- <a href={logoHref} class="flex items-center">
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-2 text-sm font-bold uppercase',
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
- isUnpin ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",