Skip to content

Commit 300c496

Browse files
SableRafclaude
andcommitted
Improve keyboard navigation and accessibility across map UI
- Add aria-haspopup and aria-expanded to share and calendar dropdown triggers - Add aria-label to calendar trigger button (was relying on title only) - Apply inert + conditional aria-modal to NodePanel and NodeList when closed, preventing hidden dialogs from intercepting tab order and screen reader focus - Add aria-label with event name to popup "See details" button - Add aria-label to "Show all hosts" expand button - Add aria-expanded to description "Read more / Show less" button - Add descriptive aria-label to node list item buttons with name, location, date - Replace hardcoded hover color in NodeList with CSS design token - Add "(opens in new tab)" to all external links (event page, share, calendar) - Reorder MapView template so controls (burger, host, theme) precede #map in DOM, fixing tab order so controls are reachable before entering the map - Remove Leaflet-injected elements (zoom controls, map container) from tab order - Move focus into popup on open; return focus to marker on Escape from popup; return focus to burger button on Escape from map container Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 73384cc commit 300c496

4 files changed

Lines changed: 64 additions & 11 deletions

File tree

pcd-website/src/components/MapView.vue

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const listOpen = ref(false);
1616
let mapInstance: import('leaflet').Map | null = null;
1717
let leafletRef: typeof import('leaflet') | null = null;
1818
const markerMap = new Map<string, import('leaflet').Marker>();
19+
let openPopupNodeId: string | null = null;
1920
2021
// --- Tile style config ---
2122
interface TileLayerConfig { url: string; options: Record<string, unknown>; }
@@ -135,6 +136,17 @@ function handleKeydown(e: KeyboardEvent) {
135136
closePanel();
136137
} else if (listOpen.value) {
137138
closeList();
139+
} else if (openPopupNodeId && mapInstance) {
140+
e.stopPropagation(); // prevent Leaflet from also handling this Escape
141+
const marker = markerMap.get(openPopupNodeId);
142+
mapInstance.closePopup();
143+
marker?.getElement()?.focus();
144+
} else {
145+
const mapEl = document.getElementById('map');
146+
if (mapEl && (mapEl === document.activeElement || mapEl.contains(document.activeElement))) {
147+
e.preventDefault();
148+
document.getElementById('burger-btn')?.focus();
149+
}
138150
}
139151
} else if (e.key === 'M' || e.key === 'm') {
140152
if (!isTextInput) {
@@ -165,6 +177,17 @@ onMounted(async () => {
165177
166178
L.control.zoom({ position: 'bottomleft' }).addTo(map);
167179
180+
// Remove Leaflet-injected elements from tab order — keyboard navigation
181+
// is handled by our own controls (burger, theme toggle, etc.)
182+
requestAnimationFrame(() => {
183+
document.querySelectorAll('.leaflet-control a, .leaflet-control button').forEach(el => {
184+
el.setAttribute('tabindex', '-1');
185+
});
186+
// Leaflet sets tabindex="0" on the map container — force it back to -1
187+
// so tab order flows through our own controls instead
188+
map.getContainer().setAttribute('tabindex', '-1');
189+
});
190+
168191
// Try to center on visitor's location, fall back to world view
169192
if (navigator.geolocation) {
170193
navigator.geolocation.getCurrentPosition(
@@ -236,6 +259,21 @@ onMounted(async () => {
236259
237260
map.addLayer(clusterGroup);
238261
262+
// Move focus into popup content when it opens
263+
map.on('popupopen', (e) => {
264+
const container = e.popup.getElement();
265+
if (!container) return;
266+
// Track which node's popup is open
267+
const btn = container.querySelector<HTMLElement>('.read-more');
268+
openPopupNodeId = btn?.getAttribute('data-node-id') ?? null;
269+
const focusTarget = container.querySelector<HTMLElement>('button, a, [tabindex]');
270+
focusTarget?.focus();
271+
});
272+
273+
map.on('popupclose', () => {
274+
openPopupNodeId = null;
275+
});
276+
239277
// Delegated click for .read-more buttons in popups
240278
const mapEl = document.getElementById('map');
241279
if (mapEl) {
@@ -271,11 +309,6 @@ onUnmounted(() => {
271309
</script>
272310

273311
<template>
274-
<div id="map" tabindex="-1" aria-label="World map of PCD 2026 nodes"></div>
275-
<a
276-
id="host-btn"
277-
:href="SUBMIT_EVENT_URL"
278-
>Submit your event</a>
279312
<button
280313
id="burger-btn"
281314
:aria-expanded="listOpen"
@@ -284,6 +317,10 @@ onUnmounted(() => {
284317
>
285318
286319
</button>
320+
<a
321+
id="host-btn"
322+
:href="SUBMIT_EVENT_URL"
323+
>Submit your event</a>
287324
<NodePanel :node="selectedNode" @close="closePanel" />
288325
<button
289326
id="theme-toggle"
@@ -305,6 +342,7 @@ onUnmounted(() => {
305342
@close="closeList"
306343
@select="onNodeSelect"
307344
/>
345+
<div id="map" tabindex="-1" aria-label="World map of PCD 2026 nodes"></div>
308346
</template>
309347

310348
<style scoped>

pcd-website/src/components/NodeList.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,11 @@ function handleKeydown(e: KeyboardEvent) {
8181
<div
8282
ref="containerRef"
8383
role="dialog"
84-
aria-modal="true"
84+
:aria-modal="open"
8585
aria-label="Node list"
8686
tabindex="-1"
8787
v-show="open"
88+
:inert="!open"
8889
class="node-list"
8990
@keydown="handleKeydown"
9091
>
@@ -103,6 +104,7 @@ function handleKeydown(e: KeyboardEvent) {
103104
<li v-for="node in sortedNodes" :key="node.id">
104105
<button
105106
class="node-item"
107+
:aria-label="`${node.event_name}, ${node.online_event ? 'Online event' : `${node.city}, ${node.country}`}, ${node.event_date ? formatDateRange(node.event_date, node.event_end_date) : 'Date TBD'}`"
106108
@click="emit('select', node)"
107109
>
108110
<span class="node-name">{{ node.event_name }}</span>
@@ -204,7 +206,7 @@ function handleKeydown(e: KeyboardEvent) {
204206
}
205207
206208
.node-item:hover {
207-
background: #f7f7f7;
209+
background: var(--color-bg-popup-hover);
208210
}
209211
210212
.node-name {

pcd-website/src/components/NodePanel.vue

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,10 @@ function getReportIssueHref(node: Node): string {
191191
<aside
192192
ref="panelRef"
193193
role="dialog"
194-
aria-modal="true"
194+
:aria-modal="node !== null"
195195
aria-labelledby="panel-title"
196196
tabindex="-1"
197+
:inert="node === null"
197198
:class="['node-panel', { 'node-panel--open': node !== null }]"
198199
>
199200
<button
@@ -226,6 +227,8 @@ function getReportIssueHref(node: Node): string {
226227
class="quick-action-btn"
227228
:aria-label="linkCopied ? 'Link copied!' : 'Share event'"
228229
:title="linkCopied ? 'Link copied!' : 'Share event'"
230+
aria-haspopup="menu"
231+
:aria-expanded="shareDropdownOpen"
229232
@click.stop="shareDropdownOpen = !shareDropdownOpen"
230233
>
231234
<Icon v-if="!linkCopied" icon="bi:box-arrow-up" width="1.3em" height="1.3em" aria-hidden="true" />
@@ -240,21 +243,25 @@ function getReportIssueHref(node: Node): string {
240243
<a
241244
:href="`https://mastodon.social/share?text=${encodeURIComponent('Join me at ' + node.event_name + ' ' + getShareUrl(node))}`"
242245
target="_blank" rel="noopener noreferrer" role="menuitem"
246+
aria-label="Share on Mastodon (opens in new tab)"
243247
@click="shareDropdownOpen = false"
244248
>Share on Mastodon</a>
245249
<a
246250
:href="`https://bsky.app/intent/compose?text=${encodeURIComponent('Join me at ' + node.event_name + ' ' + getShareUrl(node))}`"
247251
target="_blank" rel="noopener noreferrer" role="menuitem"
252+
aria-label="Share on Bluesky (opens in new tab)"
248253
@click="shareDropdownOpen = false"
249254
>Share on Bluesky</a>
250255
<a
251256
:href="`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(getShareUrl(node))}`"
252257
target="_blank" rel="noopener noreferrer" role="menuitem"
258+
aria-label="Share on Facebook (opens in new tab)"
253259
@click="shareDropdownOpen = false"
254260
>Share on Facebook</a>
255261
<a
256262
:href="`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(getShareUrl(node))}`"
257263
target="_blank" rel="noopener noreferrer" role="menuitem"
264+
aria-label="Share on LinkedIn (opens in new tab)"
258265
@click="shareDropdownOpen = false"
259266
>Share on LinkedIn</a>
260267
</div>
@@ -267,7 +274,7 @@ function getReportIssueHref(node: Node): string {
267274
<p v-if="node.organizers.some(o => o.name)" class="panel-hosts">
268275
<span class="panel-label">Hosts:</span>
269276
<span v-if="!hostsExpanded" class="panel-hosts-line">
270-
<span class="panel-hosts-names">{{ formatOrganizers(node.organizers, false) }}</span><template v-if="hasMoreHosts(node.organizers)"><span class="panel-hosts-more-wrap">…&nbsp;<button class="panel-hosts-more" @click="hostsExpanded = true">more</button></span></template>
277+
<span class="panel-hosts-names">{{ formatOrganizers(node.organizers, false) }}</span><template v-if="hasMoreHosts(node.organizers)"><span class="panel-hosts-more-wrap">…&nbsp;<button class="panel-hosts-more" aria-label="Show all hosts" @click="hostsExpanded = true">more</button></span></template>
271278
</span>
272279
<span v-else>{{ formatOrganizers(node.organizers, true) }}</span>
273280
</p>
@@ -280,6 +287,7 @@ function getReportIssueHref(node: Node): string {
280287
target="_blank"
281288
rel="noopener noreferrer"
282289
class="panel-event-website-btn"
290+
aria-label="Visit event page (opens in new tab)"
283291
>Visit event page <Icon icon="bi:box-arrow-up-right" width="1em" height="1em" aria-hidden="true" style="margin-left: 0.5rem; vertical-align: -0.1em;" /></a>
284292

285293
<!-- Info Card -->
@@ -336,7 +344,9 @@ function getReportIssueHref(node: Node): string {
336344
<div class="info-card-cal-trigger-wrap">
337345
<button
338346
class="info-card-cal-trigger"
339-
title="Add to calendar"
347+
aria-label="Add to calendar"
348+
aria-haspopup="menu"
349+
:aria-expanded="calDropdownOpen"
340350
@click.stop="calDropdownOpen = !calDropdownOpen"
341351
>
342352
Add to calendar
@@ -347,13 +357,15 @@ function getReportIssueHref(node: Node): string {
347357
target="_blank"
348358
rel="noopener noreferrer"
349359
role="menuitem"
360+
aria-label="Google Calendar (opens in new tab)"
350361
@click="calDropdownOpen = false"
351362
>Google Calendar</a>
352363
<a
353364
:href="calendarLinks(node).outlookCalUrl"
354365
target="_blank"
355366
rel="noopener noreferrer"
356367
role="menuitem"
368+
aria-label="Outlook (opens in new tab)"
357369
@click="calDropdownOpen = false"
358370
>Outlook</a>
359371
<button role="menuitem" @click="downloadIcs(node); calDropdownOpen = false">
@@ -384,6 +396,7 @@ function getReportIssueHref(node: Node): string {
384396
<button
385397
v-if="getDescPreview(node).hasMore"
386398
class="panel-read-more"
399+
:aria-expanded="descExpanded"
387400
@click="descExpanded = !descExpanded"
388401
>
389402
{{ descExpanded ? 'Show less' : 'Read more…' }}

pcd-website/src/lib/popup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function makePopupContent(node: Node): string {
9191
</div>
9292
<div class="popup-body">
9393
${descriptionHtml}
94-
<button class="read-more" data-node-id="${escapeHtml(node.id)}">See details &rarr;</button>
94+
<button class="read-more" data-node-id="${escapeHtml(node.id)}" aria-label="See details for ${escapeHtml(node.event_name)}">See details &rarr;</button>
9595
</div>
9696
</div>
9797
`.trim();

0 commit comments

Comments
 (0)