Fix pa11y issues with the map

This commit is contained in:
Stefan Bethke 2026-10-17 10:50:38 +02:00
commit f1b8c42ab6
6 changed files with 94 additions and 21 deletions

View file

@ -77,6 +77,7 @@ Each entry starts at the time given, in Hamburg time, at the Hackertours desk (w
- Each tour gets a marker in its colour, with its `icon` in white, and its route as a line; walking legs are dotted. Clicking a marker or a route shows the tour title, linked to the tour page, and the directions. - Each tour gets a marker in its colour, with its `icon` in white, and its route as a line; walking legs are dotted. Clicking a marker or a route shows the tour title, linked to the tour page, and the directions.
- The map shows all tours at once. Scroll-wheel zoom only starts after clicking into the map, so the page still scrolls normally. - The map shows all tours at once. Scroll-wheel zoom only starts after clicking into the map, so the page still scrolls normally.
- The map is only created when it scrolls near the visible part of the page, so visitors who don't scroll down load no map tiles. That also means the accessibility check (pa11y) doesn't see the map: it would otherwise report the text above the map, because axe wrongly takes the clipped map tiles to overlap it. Check changes to the map's markers and controls by hand, e.g. with the axe browser extension.
- Without JavaScript, and for screen readers, it shows a list of the tours with their directions and a link to each location on openstreetmap.org. - Without JavaScript, and for screen readers, it shows a list of the tours with their directions and a link to each location on openstreetmap.org.
- If a text is missing in the page's language, the other language is used. - If a text is missing in the page's language, the other language is used.
- Without any tours with `geo`, the shortcode renders nothing and the build logs a warning with the ID `warning-tour-map-empty`. - Without any tours with `geo`, the shortcode renders nothing and the build logs a warning with the ID `warning-tour-map-empty`.

View file

@ -88,6 +88,33 @@
} }
} }
/* The credit line on an opaque background, so its contrast doesn't depend on the map below.
As specific as Leaflet's own semi-transparent rule plus one, which it would lose to otherwise. */
.tour-map .leaflet-container .leaflet-control-attribution {
background: #fff;
color: #333;
a {
color: #0b5394;
}
}
/* Leaflet blends tiles with plus-lighter to hide seams while they fade in; the map doesn't fade
tiles (tour-map.js), and axe, which pa11y uses, fails on that blend mode. */
.tour-map .leaflet-container img.leaflet-tile {
mix-blend-mode: normal;
}
.tour-map .leaflet-bar a {
color: #111;
}
/* The zoom icons (SVG, see tour-map.js) centred in their buttons. */
.tour-map .leaflet-control-zoom a {
display: grid;
place-items: center;
}
/* Dark map tiles to match the dark colour scheme. */ /* Dark map tiles to match the dark colour scheme. */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.tour-map .leaflet-tile-pane { .tour-map .leaflet-tile-pane {

View file

@ -66,11 +66,29 @@ const popup = (tour) => {
return content; return content;
}; };
for (const element of document.querySelectorAll('[data-tour-map]')) { // Leaflet makes markers buttons, but only gives them a title, which isn't an accessible name.
const named = (marker, label) =>
marker.on('add', () => marker.getElement().setAttribute('aria-label', label));
const createMap = (element) => {
const { origin, tours } = JSON.parse(element.dataset.tourMap); const { origin, tours } = JSON.parse(element.dataset.tourMap);
// Show the map container first: Leaflet needs its size. const map = L.map(element.querySelector('.tour-map__canvas'), {
element.classList.add('tour-map--ready'); scrollWheelZoom: false,
const map = L.map(element.querySelector('.tour-map__canvas'), { scrollWheelZoom: false }); zoomControl: false,
// No fading in of tiles, see the tile blend mode in _tour-map.css.
fadeAnimation: false,
});
// Plus and minus as icons rather than characters, with names in the page's language.
L.control
.zoom({
zoomInText: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5z" fill="currentColor"/></svg>',
zoomInTitle: element.dataset.labelZoomIn,
zoomOutText: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M2 7h12v2H2z" fill="currentColor"/></svg>',
zoomOutTitle: element.dataset.labelZoomOut,
})
.addTo(map);
// Without the flag icon, so the credit line is plain text.
map.attributionControl.setPrefix('<a href="https://leafletjs.com">Leaflet</a>');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
@ -95,22 +113,45 @@ for (const element of document.querySelectorAll('[data-tour-map]')) {
.addTo(map); .addTo(map);
points.forEach((point) => bounds.extend(point)); points.forEach((point) => bounds.extend(point));
} }
L.marker([tour.lat, tour.lon], { icon: markerIcon(tour.color, tour.icon), title: tour.title }) named(L.marker([tour.lat, tour.lon], { icon: markerIcon(tour.color, tour.icon), title: tour.title }), tour.title)
.bindPopup(popup(tour), POPUP) .bindPopup(popup(tour), POPUP)
.addTo(map); .addTo(map);
bounds.extend([tour.lat, tour.lon]); bounds.extend([tour.lat, tour.lon]);
} }
if (origin.lat !== undefined) { if (origin.lat !== undefined) {
L.marker([origin.lat, origin.lon], { named(
icon: markerIcon('', origin.icon, 'origin'), L.marker([origin.lat, origin.lon], {
title: origin.name, icon: markerIcon('', origin.icon, 'origin'),
zIndexOffset: 1000, title: origin.name,
}) zIndexOffset: 1000,
}),
origin.name,
)
.bindPopup(popup({ title: origin.name }), POPUP) .bindPopup(popup({ title: origin.name }), POPUP)
.addTo(map); .addTo(map);
bounds.extend([origin.lat, origin.lon]); bounds.extend([origin.lat, origin.lon]);
} }
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 16 }); map.fitBounds(bounds, { padding: [24, 24], maxZoom: 16 });
};
// The map is only created when it comes near the visible part of the page: visitors who don't
// scroll down load no map tiles, and accessibility checkers that look at the page as loaded
// don't see the tiles, which axe wrongly takes to overlap the text above the map.
const nearView = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
nearView.unobserve(entry.target);
createMap(entry.target);
}
},
{ rootMargin: '200px' },
);
for (const element of document.querySelectorAll('[data-tour-map]')) {
// Shows the (still empty) map area at its final size, so the page doesn't jump later on.
element.classList.add('tour-map--ready');
nearView.observe(element);
} }

View file

@ -1,6 +1,11 @@
calendar_meeting_point: "Hackertours-Desk" calendar_meeting_point: "Hackertours-Desk"
calendar_ics: "iCal Download" calendar_ics: "iCal Download"
calendar_google: "Google Calendar" calendar_google: "Google Calendar"
gallery_enlarge: "Großansicht"
gallery_dialog: "Bildansicht"
gallery_close: "Schließen"
gallery_previous: "Vorheriges Bild"
gallery_next: "Nächstes Bild"
logo_alt: "Hamburg Hackertours – Startseite" logo_alt: "Hamburg Hackertours – Startseite"
map_39c3_title: "Karte der 39C3-Touren mit Wegen vom CCH (uMap)" map_39c3_title: "Karte der 39C3-Touren mit Wegen vom CCH (uMap)"
schedule_day: "Tag {{ .Day }}" schedule_day: "Tag {{ .Day }}"
@ -9,8 +14,5 @@ schedule_soldout: "ausgebucht"
skip_to_content: "Zum Inhalt springen" skip_to_content: "Zum Inhalt springen"
tour_map_label: "Karte der Tourorte" tour_map_label: "Karte der Tourorte"
tour_map_osm: "auf OpenStreetMap" tour_map_osm: "auf OpenStreetMap"
gallery_enlarge: "Großansicht" tour_map_zoom_in: "Vergrößern"
gallery_dialog: "Bildansicht" tour_map_zoom_out: "Verkleinern"
gallery_close: "Schließen"
gallery_previous: "Vorheriges Bild"
gallery_next: "Nächstes Bild"

View file

@ -1,6 +1,11 @@
calendar_ics: "iCal download" calendar_ics: "iCal download"
calendar_google: "Google calendar" calendar_google: "Google calendar"
calendar_meeting_point: "Hackertours Desk" calendar_meeting_point: "Hackertours Desk"
gallery_enlarge: "enlarge"
gallery_dialog: "Image viewer"
gallery_close: "Close"
gallery_previous: "Previous image"
gallery_next: "Next image"
logo_alt: "Hamburg Hackertours – home page" logo_alt: "Hamburg Hackertours – home page"
map_39c3_title: "Map of the 39C3 tours with routes from the CCH (uMap)" map_39c3_title: "Map of the 39C3 tours with routes from the CCH (uMap)"
schedule_day: "Day {{ .Day }}" schedule_day: "Day {{ .Day }}"
@ -9,8 +14,5 @@ schedule_soldout: "fully booked"
skip_to_content: "Skip to content" skip_to_content: "Skip to content"
tour_map_label: "Map of the tour locations" tour_map_label: "Map of the tour locations"
tour_map_osm: "on OpenStreetMap" tour_map_osm: "on OpenStreetMap"
gallery_enlarge: "enlarge" tour_map_zoom_in: "Zoom in"
gallery_dialog: "Image viewer" tour_map_zoom_out: "Zoom out"
gallery_close: "Close"
gallery_previous: "Previous image"
gallery_next: "Next image"

View file

@ -65,7 +65,7 @@ or {{< tour-map 39c3 >}}.
{{- warnidf "warning-tour-map-empty" "tour-map: no tours with geo in data/schedule/%s.yaml: %s" $event .Position -}} {{- warnidf "warning-tour-map-empty" "tour-map: no tours with geo in data/schedule/%s.yaml: %s" $event .Position -}}
{{- else -}} {{- else -}}
{{- $tours = sort $tours "title" -}} {{- $tours = sort $tours "title" -}}
<div class="tour-map" data-tour-map="{{ jsonify (dict "origin" $origin "tours" $tours) }}"> <div class="tour-map" data-tour-map="{{ jsonify (dict "origin" $origin "tours" $tours) }}" data-label-zoom-in="{{ i18n "tour_map_zoom_in" }}" data-label-zoom-out="{{ i18n "tour_map_zoom_out" }}">
<div class="tour-map__canvas" role="region" aria-label="{{ i18n "tour_map_label" }}"></div> <div class="tour-map__canvas" role="region" aria-label="{{ i18n "tour_map_label" }}"></div>
<ul class="tour-map__list"> <ul class="tour-map__list">
{{- range $tours }} {{- range $tours }}