From 9bbfc793faea2ea771132987562161aa632f28cb Mon Sep 17 00:00:00 2001 From: aryansk Date: Fri, 14 Aug 2026 12:51:17 +0530 Subject: [PATCH] fix(a11y): label cluster markers and announce zoom-guard to screen readers Cluster markers are divIcons with no accessible name, so a screen-reader user hears an unlabeled button with no hint of what it represents. Give each cluster a role=button, tabindex=0, and an aria-label summarizing its contents ("Cluster of 11 places, mostly SAT centre"), and make Enter/Space trigger the same zoom-in that click does. ScrollZoomGuard's hint is aria-hidden and only fires on mouse wheel, so keyboard users get no equivalent messaging. Add an sr-only note that announces the keyboard zoom alternative (+/- keys or Ctrl/Cmd + scroll). Closes #99 --- src/components/map/map-view.tsx | 44 +++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 5fd56d3..4811fe0 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -16,7 +16,7 @@ import { } from "react-leaflet"; import type { Place, PlaceType } from "@/lib/types"; -import { PLACE_TYPES } from "@/lib/types"; +import { PLACE_TYPES, PLACE_TYPE_LABELS } from "@/lib/types"; import type { LatLng } from "@/lib/geo"; import studyMapConfig from "../../../studymap.config"; import type { Bounds } from "@/lib/places"; @@ -31,7 +31,7 @@ function emptyTypeCounts(): ClusterAggProps { } /** Builds a pie-style divIcon: a conic-gradient ring colored by place type, count in the middle. */ -function clusterIcon(counts: ClusterAggProps, total: number): L.DivIcon { +function clusterIcon(counts: ClusterAggProps, total: number, label: string): L.DivIcon { let acc = 0; const stops: string[] = []; for (const type of PLACE_TYPES) { @@ -44,13 +44,29 @@ function clusterIcon(counts: ClusterAggProps, total: number): L.DivIcon { } const size = total < 10 ? 36 : total < 50 ? 44 : 54; return L.divIcon({ - html: `
${total}
`, + html: `
${total}
`, className: "cluster-pie-icon", iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } +/** Summarizes a cluster's contents for assistive tech, e.g. "Cluster of 11 places, mostly SAT centre". */ +function clusterLabel(counts: ClusterAggProps, total: number): string { + let dominant: PlaceType | null = null; + let dominantCount = 0; + for (const type of PLACE_TYPES) { + if (counts[type] > dominantCount) { + dominant = type; + dominantCount = counts[type]; + } + } + if (!dominant || dominant === "other_places") { + return `Cluster of ${total} ${total === 1 ? "place" : "places"}`; + } + return `Cluster of ${total} ${total === 1 ? "place" : "places"}, mostly ${PLACE_TYPE_LABELS[dominant].toLowerCase()}`; +} + function pinIcon(color: string): L.DivIcon { return L.divIcon({ html: `
`, @@ -129,7 +145,7 @@ function ClusteredMarkers({ places }: { places: Place[] }) { { const expansionZoom = Math.min( @@ -138,6 +154,15 @@ function ClusteredMarkers({ places }: { places: Place[] }) { ); map.flyTo([lat, lng], expansionZoom, { duration: 0.5 }); }, + keydown: (e) => { + if (e.originalEvent.key !== "Enter" && e.originalEvent.key !== " ") return; + e.originalEvent.preventDefault(); + const expansionZoom = Math.min( + index.getClusterExpansionZoom(clusterId), + 18, + ); + map.flyTo([lat, lng], expansionZoom, { duration: 0.5 }); + }, }} /> ); @@ -290,9 +315,14 @@ function ScrollZoomGuard() { }, [map]); return ( -
- Use Ctrl + scroll to zoom the map -
+ <> +
+ Use Ctrl + scroll to zoom the map +
+ + Zoom the map with the + and - keys, or Ctrl/Cmd + scroll + + ); }