Skip to content

Commit 177a8b4

Browse files
SableRafclaude
andcommitted
Improve map accessibility: zoom order, marker labels, focus management, cluster order
- Move zoom controls before marker pane in DOM so screen readers and tab order encounter zoom in/out before individual markers - Keep zoom and attribution links in tab order; only remove other Leaflet controls (they were all being stripped before) - Move attribution to end of map container so it appears last in tab order - Apply aria-label with event name to each marker element; re-apply on animationend so labels survive cluster expand/collapse cycles - onNodeSelect now calls openPanel() directly after flyTo instead of opening a popup, so selecting from the list goes straight to the panel - Add await nextTick() before focus trap activation in NodePanel so the panel's CSS transition has started and tabButtonRef.offsetParent is non-null when focus is set - Sort marker pane so cluster elements appear before individual markers; re-sort on animationend - On spiderfied event, insert each child marker element immediately after its cluster element so spiderweb nodes appear next in tab/reading order Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 300c496 commit 177a8b4

2 files changed

Lines changed: 76 additions & 6 deletions

File tree

pcd-website/src/components/MapView.vue

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ function onNodeSelect(node: Node) {
121121
if (mapInstance && marker) {
122122
mapInstance.flyTo([node.lat, node.lng], 5, { duration: 1 });
123123
setTimeout(() => {
124-
marker.openPopup();
124+
openPanel(node);
125125
}, 1100);
126126
}
127127
}
@@ -177,15 +177,38 @@ onMounted(async () => {
177177
178178
L.control.zoom({ position: 'bottomleft' }).addTo(map);
179179
180-
// Remove Leaflet-injected elements from tab order — keyboard navigation
181-
// is handled by our own controls (burger, theme toggle, etc.)
180+
// Manage tab order for Leaflet-injected elements:
181+
// - Zoom buttons stay in tab order (they are our primary map keyboard controls)
182+
// - Attribution links stay in tab order but move to end of DOM (after markers)
183+
// - All other Leaflet controls are removed from tab order
182184
requestAnimationFrame(() => {
183185
document.querySelectorAll('.leaflet-control a, .leaflet-control button').forEach(el => {
184-
el.setAttribute('tabindex', '-1');
186+
const inZoom = el.closest('.leaflet-control-zoom');
187+
const inAttribution = el.closest('.leaflet-control-attribution');
188+
if (!inZoom && !inAttribution) {
189+
el.setAttribute('tabindex', '-1');
190+
}
185191
});
186192
// Leaflet sets tabindex="0" on the map container — force it back to -1
187193
// so tab order flows through our own controls instead
188194
map.getContainer().setAttribute('tabindex', '-1');
195+
196+
const mapContainer = map.getContainer();
197+
const controlContainer = mapContainer.querySelector('.leaflet-control-container');
198+
const mapPane = mapContainer.querySelector('.leaflet-map-pane');
199+
200+
// Move zoom controls before the marker pane so screen readers and tab order
201+
// encounter zoom buttons before individual markers
202+
if (controlContainer && mapPane) {
203+
mapContainer.insertBefore(controlContainer, mapPane);
204+
}
205+
206+
// Move attribution control to the very end of the map container so it
207+
// appears last in tab order (after all markers)
208+
const attribution = mapContainer.querySelector('.leaflet-control-attribution')?.closest('.leaflet-bottom');
209+
if (attribution) {
210+
mapContainer.appendChild(attribution);
211+
}
189212
});
190213
191214
// Try to center on visitor's location, fall back to world view
@@ -259,6 +282,52 @@ onMounted(async () => {
259282
260283
map.addLayer(clusterGroup);
261284
285+
// Apply accessible names to marker elements. Leaflet creates marker DOM elements
286+
// lazily, so we apply after layer is added and re-apply whenever the cluster
287+
// animates (which shows/hides individual markers as clusters form or dissolve).
288+
function applyMarkerLabels() {
289+
markerMap.forEach((marker, id) => {
290+
const node = props.nodes.find(n => n.id === id);
291+
if (node) {
292+
marker.getElement()?.setAttribute('aria-label', node.event_name);
293+
}
294+
});
295+
}
296+
297+
// Sort the marker pane so cluster markers appear before individual markers
298+
// in the DOM, giving screen readers a logical order: clusters first, then nodes.
299+
function sortMarkerPane() {
300+
const pane = map.getPanes().markerPane;
301+
if (!pane) return;
302+
const clusters = Array.from(pane.querySelectorAll<HTMLElement>('.marker-cluster-custom'));
303+
const markers = Array.from(pane.querySelectorAll<HTMLElement>('.marker-node'));
304+
clusters.forEach(el => pane.appendChild(el));
305+
markers.forEach(el => pane.appendChild(el));
306+
clusters.forEach(el => pane.insertBefore(el, pane.firstChild));
307+
}
308+
309+
setTimeout(() => { applyMarkerLabels(); sortMarkerPane(); }, 0);
310+
clusterGroup.on('animationend', () => { applyMarkerLabels(); sortMarkerPane(); });
311+
312+
// When a cluster spiderfies, move its child marker elements immediately after
313+
// the cluster's own element in the DOM so screen readers encounter them next.
314+
clusterGroup.on('spiderfied', (e: { cluster: import('leaflet').Layer; markers: import('leaflet').Marker[] }) => {
315+
applyMarkerLabels();
316+
const pane = map.getPanes().markerPane;
317+
if (!pane) return;
318+
const clusterEl = (e.cluster as import('leaflet').Marker).getElement?.();
319+
if (!clusterEl) return;
320+
// Insert each child element right after the cluster element
321+
let insertAfter: Element = clusterEl;
322+
e.markers.forEach((m) => {
323+
const el = m.getElement?.();
324+
if (el && el !== insertAfter) {
325+
insertAfter.after(el);
326+
insertAfter = el;
327+
}
328+
});
329+
});
330+
262331
// Move focus into popup content when it opens
263332
map.on('popupopen', (e) => {
264333
const container = e.popup.getElement();

pcd-website/src/components/NodePanel.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,12 @@ async function initMinimap(node: Node) {
109109
110110
watch(
111111
() => props.node,
112-
(newNode) => {
112+
async (newNode) => {
113113
calDropdownOpen.value = false;
114114
descExpanded.value = false;
115115
hostsExpanded.value = false;
116116
if (newNode) {
117+
await nextTick();
117118
trap?.activate();
118119
if (!newNode.online_event && !newNode.location_tbd) initMinimap(newNode);
119120
else destroyMinimap();
@@ -227,7 +228,7 @@ function getReportIssueHref(node: Node): string {
227228
class="quick-action-btn"
228229
:aria-label="linkCopied ? 'Link copied!' : 'Share event'"
229230
:title="linkCopied ? 'Link copied!' : 'Share event'"
230-
aria-haspopup="menu"
231+
aria-haspopup="menu"
231232
:aria-expanded="shareDropdownOpen"
232233
@click.stop="shareDropdownOpen = !shareDropdownOpen"
233234
>

0 commit comments

Comments
 (0)