Skip to content

Commit fb3d670

Browse files
fix(a11y): WCAG 2.2 AA fixes for portal, admin, marketing layouts
- Add a global skip-link to #main-content in src/routes/+layout.svelte (2.4.1) - Wrap portal/admin content in <main id="main-content"> instead of an unlandmarked <div> (1.3.1) - Resolve heading-order skip: portal/admin header titles are no longer <h2> next to per-page <h1> — they're now visually-styled <p role="presentation"> so each page has exactly one h1 and the heading outline is consistent (1.3.1, 2.4.6) - Hamburger and sidebar-close buttons get aria-label, aria-expanded, aria-controls + aria-hidden on their inner icons (4.1.2) - Sidebar overlay: role="button" + tabindex="0" + Enter/Space/Escape handler so it's keyboard operable (2.1.1) - Footer link groups become <nav aria-labelledby> with real <h2> headings; external links append a visually-hidden "opens in new window" hint and use rel="noopener noreferrer" (1.3.1, 2.4.4, 3.2.5) - Email-log search input gets a real <label> (visually-hidden) and type="search" (3.3.2) - Register form: organization name input gets autocomplete="organization", aria-required, aria-invalid + aria-describedby for error linkage; the * marker is hidden from AT and replaced with a sr-only "required" label (3.3.2, 1.3.5) - Replace the bare 'outline: none; border-color' on .pg-input:focus with a proper outline that downgrades only when :focus-visible is supported, so non-focus-visible browsers still show a focus ring (2.4.7) - Add .visually-hidden alias for .sr-only Closes #49
1 parent ea5e693 commit fb3d670

7 files changed

Lines changed: 129 additions & 35 deletions

File tree

src/lib/components/Footer.svelte

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,22 @@
1919
</div>
2020

2121
<div class="footer-links">
22-
<div class="link-group">
23-
<span class="group-title">{$_('footer.product')}</span>
22+
<nav class="link-group" aria-labelledby="footer-product-title">
23+
<h2 id="footer-product-title" class="group-title">{$_('footer.product')}</h2>
2424
<a href="/pricing">{$_('nav.pricing')}</a>
2525
<a href="/register">{$_('nav.register')}</a>
26-
</div>
27-
<div class="link-group">
28-
<span class="group-title">{$_('footer.resources')}</span>
29-
<a href="https://postguard.eu" target="_blank" rel="noopener">{$_('footer.personal')}</a>
30-
<a href="https://docs.postguard.eu" target="_blank" rel="noopener">{$_('footer.docs')}</a>
31-
</div>
26+
</nav>
27+
<nav class="link-group" aria-labelledby="footer-resources-title">
28+
<h2 id="footer-resources-title" class="group-title">{$_('footer.resources')}</h2>
29+
<a href="https://postguard.eu" target="_blank" rel="noopener noreferrer">
30+
{$_('footer.personal')}
31+
<span class="visually-hidden"> ({$_('common.opensInNewWindow', { default: 'opens in new window' })})</span>
32+
</a>
33+
<a href="https://docs.postguard.eu" target="_blank" rel="noopener noreferrer">
34+
{$_('footer.docs')}
35+
<span class="visually-hidden"> ({$_('common.opensInNewWindow', { default: 'opens in new window' })})</span>
36+
</a>
37+
</nav>
3238
</div>
3339

3440
<div class="footer-bottom">
@@ -92,6 +98,19 @@
9298
letter-spacing: 0.5px;
9399
line-height: normal;
94100
margin: 0 0 0.25rem;
101+
font-family: inherit;
102+
}
103+
104+
.visually-hidden {
105+
position: absolute;
106+
width: 1px;
107+
height: 1px;
108+
padding: 0;
109+
margin: -1px;
110+
overflow: hidden;
111+
clip: rect(0, 0, 0, 0);
112+
white-space: nowrap;
113+
border: 0;
95114
}
96115
97116
a {

src/lib/global.scss

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,17 +217,17 @@ textarea.pg-input:hover {
217217
input.pg-input:focus,
218218
select.pg-input:focus,
219219
textarea.pg-input:focus {
220-
outline: none;
221220
border-color: var(--pg-primary-contrast);
222-
}
223-
224-
input.pg-input:focus-visible,
225-
select.pg-input:focus-visible,
226-
textarea.pg-input:focus-visible {
227221
outline: 2px solid var(--pg-primary);
228222
outline-offset: 1px;
229223
}
230224

225+
input.pg-input:focus:not(:focus-visible),
226+
select.pg-input:focus:not(:focus-visible),
227+
textarea.pg-input:focus:not(:focus-visible) {
228+
outline: none;
229+
}
230+
231231
input.pg-input:invalid:not(:focus):not(:placeholder-shown),
232232
select.pg-input:invalid:not(:focus),
233233
textarea.pg-input:invalid:not(:focus):not(:placeholder-shown) {
@@ -263,7 +263,8 @@ textarea.pg-input:disabled {
263263
}
264264

265265
/* Utility classes */
266-
.sr-only {
266+
.sr-only,
267+
.visually-hidden {
267268
position: absolute;
268269
left: -10000px;
269270
top: auto;

src/routes/(admin)/+layout.svelte

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,18 @@
3636
{/if}
3737

3838
<div class="admin-layout">
39-
<aside class="sidebar" class:open={sidebarOpen}>
39+
<aside id="admin-sidebar" class="sidebar" class:open={sidebarOpen}>
4040
<div class="sidebar-header">
4141
<a href="/admin/organizations" class="sidebar-logo">
4242
<img src={logoLight} alt="PostGuard" class="logo-img light-only" height="22" /><img src={logoDark} alt="PostGuard" class="logo-img dark-only" height="22" />
4343
<span class="logo-badge">Business</span>
4444
</a>
45-
<button class="sidebar-close desktop-hide" onclick={() => (sidebarOpen = false)}>
46-
<Icon icon="mdi:close" width="20" height="20" />
45+
<button
46+
class="sidebar-close desktop-hide"
47+
onclick={() => (sidebarOpen = false)}
48+
aria-label={$_('nav.closeMenu', { default: 'Close navigation menu' })}
49+
>
50+
<Icon icon="mdi:close" width="20" height="20" aria-hidden="true" />
4751
</button>
4852
</div>
4953

@@ -73,24 +77,42 @@
7377

7478
<div class="admin-main">
7579
<header class="admin-header">
76-
<button class="hamburger desktop-hide" onclick={() => (sidebarOpen = true)}>
80+
<button
81+
class="hamburger desktop-hide"
82+
onclick={() => (sidebarOpen = true)}
83+
aria-label={$_('nav.openMenu', { default: 'Open navigation menu' })}
84+
aria-expanded={sidebarOpen}
85+
aria-controls="admin-sidebar"
86+
>
7787
<Icon icon="mdi:menu" width="24" height="24" />
7888
</button>
79-
<h2 class="header-title">{$_('nav.adminPanel')}</h2>
89+
<p class="header-title" role="presentation">{$_('nav.adminPanel')}</p>
8090
<div class="header-actions">
8191
<LocaleSwitcher />
8292
<ThemeSwitcher />
8393
</div>
8494
</header>
85-
<div class="admin-content">
95+
<main id="main-content" class="admin-content">
8696
{@render children()}
87-
</div>
97+
</main>
8898
</div>
8999
</div>
90100
</div>
91101

92102
{#if sidebarOpen}
93-
<div class="sidebar-overlay desktop-hide" role="presentation" onclick={() => (sidebarOpen = false)}></div>
103+
<div
104+
class="sidebar-overlay desktop-hide"
105+
role="button"
106+
tabindex="0"
107+
aria-label={$_('nav.closeMenu', { default: 'Close navigation menu' })}
108+
onclick={() => (sidebarOpen = false)}
109+
onkeydown={(e) => {
110+
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
111+
e.preventDefault();
112+
sidebarOpen = false;
113+
}
114+
}}
115+
></div>
94116
{/if}
95117

96118
<style lang="scss">

src/routes/(marketing)/register/+page.svelte

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,18 +196,26 @@
196196
<input type="hidden" name="domain" value={derivedDomain} />
197197

198198
<div class="form-group">
199-
<label for="name">{$_('register.orgName')} *</label>
199+
<label for="name">
200+
{$_('register.orgName')}
201+
<span aria-hidden="true">*</span>
202+
<span class="sr-only">({$_('common.required', { default: 'required' })})</span>
203+
</label>
200204
<input
201205
id="name"
202206
name="name"
203207
type="text"
208+
autocomplete="organization"
204209
class="pg-input"
205210
placeholder={$_('register.orgNamePlaceholder')}
206211
value={form?.values?.name ?? ''}
207212
required
213+
aria-required="true"
214+
aria-invalid={form?.errors?.name ? 'true' : undefined}
215+
aria-describedby={form?.errors?.name ? 'name-error' : undefined}
208216
/>
209217
{#if form?.errors?.name}
210-
<span class="field-error">{form.errors.name}</span>
218+
<span id="name-error" class="field-error">{form.errors.name}</span>
211219
{/if}
212220
</div>
213221

src/routes/(portal)/+layout.svelte

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,18 @@
4242
{/if}
4343

4444
<div class="portal">
45-
<aside class="sidebar" class:open={sidebarOpen}>
45+
<aside id="portal-sidebar" class="sidebar" class:open={sidebarOpen}>
4646
<div class="sidebar-header">
4747
<a href="/" class="sidebar-logo">
4848
<img src={logoLight} alt="PostGuard" class="logo-img light-only" height="22" /><img src={logoDark} alt="PostGuard" class="logo-img dark-only" height="22" />
4949
<span class="logo-badge">Business</span>
5050
</a>
51-
<button class="sidebar-close desktop-hide" onclick={() => (sidebarOpen = false)}>
52-
<Icon icon="mdi:close" width="20" height="20" />
51+
<button
52+
class="sidebar-close desktop-hide"
53+
onclick={() => (sidebarOpen = false)}
54+
aria-label={$_('nav.closeMenu', { default: 'Close navigation menu' })}
55+
>
56+
<Icon icon="mdi:close" width="20" height="20" aria-hidden="true" />
5357
</button>
5458
</div>
5559

@@ -79,24 +83,42 @@
7983

8084
<div class="portal-main">
8185
<header class="portal-header">
82-
<button class="hamburger desktop-hide" onclick={() => (sidebarOpen = true)}>
86+
<button
87+
class="hamburger desktop-hide"
88+
onclick={() => (sidebarOpen = true)}
89+
aria-label={$_('nav.openMenu', { default: 'Open navigation menu' })}
90+
aria-expanded={sidebarOpen}
91+
aria-controls="portal-sidebar"
92+
>
8393
<Icon icon="mdi:menu" width="24" height="24" />
8494
</button>
85-
<h2 class="portal-title">{data.organization.name}</h2>
95+
<p class="portal-title" role="presentation">{data.organization.name}</p>
8696
<div class="header-actions">
8797
<LocaleSwitcher />
8898
<ThemeSwitcher />
8999
</div>
90100
</header>
91-
<div class="portal-content">
101+
<main id="main-content" class="portal-content">
92102
{@render children()}
93-
</div>
103+
</main>
94104
</div>
95105
</div>
96106
</div>
97107

98108
{#if sidebarOpen}
99-
<div class="sidebar-overlay desktop-hide" role="presentation" onclick={() => (sidebarOpen = false)}></div>
109+
<div
110+
class="sidebar-overlay desktop-hide"
111+
role="button"
112+
tabindex="0"
113+
aria-label={$_('nav.closeMenu', { default: 'Close navigation menu' })}
114+
onclick={() => (sidebarOpen = false)}
115+
onkeydown={(e) => {
116+
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
117+
e.preventDefault();
118+
sidebarOpen = false;
119+
}
120+
}}
121+
></div>
100122
{/if}
101123

102124
<style lang="scss">

src/routes/(portal)/portal/email-log/+page.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,16 @@
2626
<div class="search-bar">
2727
<form onsubmit={(e) => { e.preventDefault(); search(); }}>
2828
<div class="search-row">
29+
<label for="email-log-search" class="visually-hidden">{$_('emailLog.search')}</label>
2930
<input
30-
type="text"
31+
id="email-log-search"
32+
type="search"
3133
class="pg-input"
3234
placeholder={$_('emailLog.searchPlaceholder')}
3335
bind:value={searchInput}
3436
/>
3537
<button type="submit" class="secondary-btn">
36-
<Icon icon="mdi:magnify" width="18" height="18" />
38+
<Icon icon="mdi:magnify" width="18" height="18" aria-hidden="true" />
3739
{$_('emailLog.search')}
3840
</button>
3941
</div>

src/routes/+layout.svelte

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@
1111
});
1212
</script>
1313

14+
<a href="#main-content" class="skip-link">Skip to main content</a>
15+
1416
<div id="app">
1517
{@render children()}
1618
</div>
19+
20+
<style>
21+
.skip-link {
22+
position: absolute;
23+
top: -40px;
24+
left: 0;
25+
padding: 8px 16px;
26+
background: var(--pg-primary-bg, #7c3aed);
27+
color: #fff;
28+
text-decoration: none;
29+
z-index: 1000;
30+
}
31+
.skip-link:focus {
32+
top: 0;
33+
outline: 2px solid #fff;
34+
outline-offset: 2px;
35+
}
36+
</style>

0 commit comments

Comments
 (0)