@defra/interactive-map 0.0.43-fmp-experimental → 0.0.45-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/assets/templates/add-polygons.njk +11 -7
  2. package/assets/templates/add-symbols.njk +5 -1
  3. package/assets/templates/select-feature.njk +72 -15
  4. package/dist/css/index.css +1 -1
  5. package/dist/esm/im-core.js +1 -1
  6. package/dist/esm/im-shell.js +1 -1
  7. package/dist/umd/im-core.js +1 -1
  8. package/dist/umd/index.js +1 -1
  9. package/docs/api/context.md +24 -0
  10. package/docs/api/keyboard-shortcut-definition.md +102 -0
  11. package/docs/api.md +18 -8
  12. package/docs/building-a-plugin.md +1 -0
  13. package/docs/examples/add-marker-with-panel.mdx +4 -0
  14. package/docs/examples/add-polygons.mdx +16 -12
  15. package/docs/examples/add-symbols.mdx +4 -0
  16. package/docs/examples/basic-map.mdx +4 -0
  17. package/docs/examples/button-map.mdx +4 -0
  18. package/docs/examples/draw-tools.mdx +4 -0
  19. package/docs/examples/place-marker.mdx +4 -0
  20. package/docs/examples/search-control.mdx +4 -0
  21. package/docs/examples/select-feature.mdx +4 -0
  22. package/docs/examples/style-switcher.mdx +36 -44
  23. package/docs/examples/toggle-marker-label.mdx +4 -0
  24. package/docs/plugins/draw.md +2 -2
  25. package/docs/plugins/plugin-manifest.md +9 -0
  26. package/package.json +4 -2
  27. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  28. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -0
  29. package/plugins/beta/draw-es/src/events.js +17 -8
  30. package/plugins/beta/draw-es/src/reducer.js +8 -1
  31. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  32. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  33. package/plugins/beta/draw-ol/dist/css/index.css +1 -13
  34. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  35. package/plugins/beta/frame/dist/css/index.css +1 -11
  36. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  37. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  38. package/plugins/beta/frame/src/Frame.jsx +28 -2
  39. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  40. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  41. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  42. package/plugins/beta/scale-bar/dist/css/index.css +1 -8
  43. package/plugins/datasets/dist/css/5648.index.css +1 -5
  44. package/plugins/datasets/dist/css/index.css +1 -1
  45. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  46. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  47. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  48. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  49. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  50. package/plugins/datasets/src/registry/dataset.js +8 -12
  51. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  52. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  53. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  54. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  55. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  56. package/plugins/draw/dist/esm/index.js +1 -1
  57. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  58. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  59. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  60. package/plugins/draw/dist/umd/index.js +1 -1
  61. package/plugins/draw/src/DrawInit.jsx +41 -26
  62. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  63. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  64. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  65. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  66. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  67. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  68. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  69. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  70. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +23 -2
  71. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +53 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  73. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  74. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  75. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  76. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  77. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  78. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  79. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  80. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  86. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  87. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  88. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  89. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  90. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  91. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  92. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  93. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  94. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  95. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  96. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  97. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  98. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  99. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  100. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  101. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  102. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +40 -17
  103. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  104. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  105. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  106. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  107. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  108. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  109. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  110. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  111. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  112. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +40 -54
  113. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +32 -13
  114. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  115. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  116. package/plugins/draw/src/events.js +4 -4
  117. package/plugins/draw/src/events.test.js +1 -1
  118. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  119. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  120. package/plugins/draw/src/manifest.js +4 -1
  121. package/plugins/draw/src/manifest.test.js +19 -7
  122. package/plugins/draw/src/utils/spatial.js +1 -31
  123. package/plugins/draw/src/utils/spatial.test.js +1 -24
  124. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  125. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  126. package/plugins/interact/dist/umd/index.js +1 -1
  127. package/plugins/interact/src/InteractInit.jsx +2 -2
  128. package/plugins/interact/src/InteractInit.test.js +3 -3
  129. package/plugins/interact/src/events.js +11 -2
  130. package/plugins/interact/src/events.test.js +32 -0
  131. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  132. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  133. package/plugins/interact/src/hooks/useCrossHairVisibility.js +9 -4
  134. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  135. package/plugins/interact/src/hooks/useSpatialList.js +251 -0
  136. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +216 -70
  137. package/plugins/interact/src/manifest.js +12 -1
  138. package/plugins/interact/src/manifest.test.js +20 -0
  139. package/plugins/interact/src/utils/spatial.js +24 -1
  140. package/plugins/interact/src/utils/spatial.test.js +26 -1
  141. package/plugins/map-key/dist/css/index.css +1 -106
  142. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  143. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  144. package/plugins/map-key/dist/umd/index.js +1 -1
  145. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  146. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  147. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  148. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  149. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  150. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  151. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  152. package/plugins/map-key/src/manifest.js +1 -1
  153. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  154. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  155. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  156. package/plugins/menu/dist/css/index.css +1 -80
  157. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  158. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  159. package/plugins/menu/dist/umd/index.js +1 -1
  160. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  161. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  162. package/plugins/menu/src/initialise/createMenu.js +3 -1
  163. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  164. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  165. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  166. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  167. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  168. package/plugins/menu/src/reducers/pluginState.js +1 -6
  169. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  170. package/plugins/search/dist/css/index.css +1 -1
  171. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  172. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  173. package/plugins/search/src/components/Form/Form.jsx +3 -3
  174. package/plugins/search/src/components/Suggestions/Suggestions.jsx +2 -2
  175. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +1 -1
  176. package/plugins/search/src/manifest.js +5 -9
  177. package/plugins/search/src/manifest.test.js +5 -0
  178. package/providers/beta/esri/dist/css/index.css +1 -34
  179. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  180. package/providers/beta/esri/src/utils/spatial.js +15 -13
  181. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  182. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  183. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  184. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  185. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  186. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  187. package/providers/beta/openlayers/src/utils/spatial.js +16 -14
  188. package/providers/beta/openlayers/src/utils/spatial.test.js +10 -6
  189. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  190. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  191. package/providers/maplibre/dist/umd/index.js +1 -1
  192. package/providers/maplibre/src/defaults.test.js +26 -0
  193. package/providers/maplibre/src/maplibreProvider.js +1 -1
  194. package/providers/maplibre/src/utils/labels.js +2 -2
  195. package/providers/maplibre/src/utils/labels.test.js +5 -5
  196. package/providers/maplibre/src/utils/spatial.js +19 -74
  197. package/providers/maplibre/src/utils/spatial.test.js +8 -32
  198. package/rollup.esm.mjs +2 -1
  199. package/scripts/publish-package.sh +36 -0
  200. package/src/App/App.jsx +1 -1
  201. package/src/App/App.test.jsx +6 -1
  202. package/src/App/components/CrossHair/CrossHair.jsx +25 -11
  203. package/src/App/components/CrossHair/CrossHair.module.scss +37 -12
  204. package/src/App/components/CrossHair/CrossHair.test.jsx +40 -11
  205. package/src/App/components/Hints/Hints.jsx +3 -3
  206. package/src/App/components/Hints/Hints.test.jsx +3 -3
  207. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  208. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  209. package/src/App/components/MapButton/MapButton.jsx +19 -7
  210. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  211. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  212. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  213. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  214. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  215. package/src/App/components/Markers/Markers.jsx +2 -2
  216. package/src/App/components/Markers/Markers.test.jsx +3 -3
  217. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  218. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  219. package/src/App/components/Panel/Panel.jsx +19 -20
  220. package/src/App/components/Panel/Panel.module.scss +9 -0
  221. package/src/App/components/Panel/Panel.test.jsx +69 -4
  222. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  223. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  224. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  225. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  226. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  227. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +6 -6
  228. package/src/App/components/Viewport/SpatialList.test.jsx +285 -0
  229. package/src/App/components/Viewport/Viewport.jsx +21 -11
  230. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  231. package/src/App/controls/keyboardMappings.js +7 -2
  232. package/src/App/controls/keyboardMappings.test.js +21 -0
  233. package/src/App/controls/keyboardShortcuts.js +16 -2
  234. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  235. package/src/App/hooks/useCrossHairAPI.js +29 -1
  236. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  237. package/src/App/hooks/useHintsAPI.js +5 -5
  238. package/src/App/hooks/useInterfaceAPI.js +2 -1
  239. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  240. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  241. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  242. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  243. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  244. package/src/App/hooks/useSpatialListFocus.js +296 -0
  245. package/src/App/hooks/useSpatialListFocus.test.js +957 -0
  246. package/src/App/hooks/useSpatialListItems.js +51 -0
  247. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  248. package/src/App/initialiseApp.js +6 -3
  249. package/src/App/initialiseApp.test.js +11 -0
  250. package/src/App/layout/Layout.jsx +4 -2
  251. package/src/App/layout/Layout.test.jsx +21 -0
  252. package/src/App/layout/layout.module.scss +7 -1
  253. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  254. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  255. package/src/App/registry/pluginRegistry.js +13 -0
  256. package/src/App/registry/pluginRegistry.test.js +15 -0
  257. package/src/App/registry/spatialListRegistry.js +104 -0
  258. package/src/App/registry/spatialListRegistry.test.js +218 -0
  259. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  260. package/src/App/renderer/mapButtons.js +4 -0
  261. package/src/App/renderer/mapButtons.test.js +20 -0
  262. package/src/App/renderer/mapPanels.js +33 -53
  263. package/src/App/renderer/mapPanels.test.js +29 -4
  264. package/src/App/renderer/slotHelpers.js +33 -0
  265. package/src/App/renderer/slotHelpers.test.js +79 -2
  266. package/src/App/store/AppProvider.jsx +3 -2
  267. package/src/App/store/ServiceProvider.jsx +5 -3
  268. package/src/App/store/ServiceProvider.test.jsx +20 -1
  269. package/src/InteractiveMap/InteractiveMap.js +13 -2
  270. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  271. package/src/config/appConfig.js +17 -17
  272. package/src/config/appConfig.test.js +19 -19
  273. package/src/config/defaults.js +2 -2
  274. package/src/config/events.js +8 -8
  275. package/src/scss/main.scss +2 -2
  276. package/src/scss/tools/_govuk-content.scss +29 -0
  277. package/src/scss/tools/_index.scss +2 -1
  278. package/src/test-utils.js +8 -0
  279. package/src/types.js +53 -11
  280. package/src/utils/findNearestItemInDirection.js +29 -0
  281. package/src/utils/findNearestItemInDirection.test.js +67 -0
  282. package/src/utils/getPanelElementId.js +4 -0
  283. package/src/utils/getPanelElementId.test.js +11 -0
  284. package/src/utils/getPanelRole.js +22 -0
  285. package/src/utils/getPanelRole.test.js +56 -0
  286. package/src/utils/globalAltShortcuts.js +12 -0
  287. package/src/utils/globalAltShortcuts.test.js +27 -0
  288. package/src/utils/spatialNavigate.js +49 -0
  289. package/src/utils/spatialNavigate.test.js +42 -0
  290. package/webpack.umd.mjs +6 -1
  291. package/plugins/interact/src/hooks/useMapItemList.js +0 -187
  292. package/src/App/components/Viewport/Features.jsx +0 -35
  293. package/src/App/components/Viewport/Features.test.jsx +0 -124
  294. package/src/App/hooks/useFeatureFocus.js +0 -199
  295. package/src/App/hooks/useFeatureFocus.test.js +0 -635
  296. package/src/App/hooks/useFeatureItems.js +0 -39
  297. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -7,7 +7,7 @@ import turfBbox from '@turf/bbox'
7
7
  // -----------------------------------------------------------------------------
8
8
 
9
9
  /**
10
- * Calculate distance in meters between two [lng, lat] coordinates
10
+ * Calculate distance in metres between two [lng, lat] coordinates
11
11
  */
12
12
  const getDistance = (from, to) => {
13
13
  const [lng1, lat1] = from
@@ -16,22 +16,24 @@ const getDistance = (from, to) => {
16
16
  const fromLatLon = new LatLon(lat1, lng1)
17
17
  const toLatLon = new LatLon(lat2, lng2)
18
18
 
19
- return fromLatLon.distanceTo(toLatLon) // meters
19
+ return fromLatLon.distanceTo(toLatLon) // metres
20
20
  }
21
21
 
22
22
  /**
23
- * Format dimension, meters if less than 0.5 miles, otherwise miles
23
+ * Format dimension, metres if less than 0.5 miles, otherwise miles
24
24
  */
25
- const formatDimension = (meters) => {
25
+ const formatDimension = (metres) => {
26
26
  const WHOLE_MILE_THRESHOLD = 10
27
27
  const MILE_THRESHOLD = 0.5
28
- const METERS_PER_MILE = 1609.344
28
+ const METRES_PER_MILE = 1609.344
29
29
 
30
- const miles = meters / METERS_PER_MILE
30
+ const miles = metres / METRES_PER_MILE
31
31
 
32
32
  // Check if we are under the half-mile threshold
33
33
  if (miles < MILE_THRESHOLD) {
34
- return `${Math.round(meters)}m`
34
+ const roundedMetres = Math.round(metres)
35
+ const units = roundedMetres === 1 ? 'metre' : 'metres'
36
+ return `${roundedMetres} ${units}`
35
37
  }
36
38
 
37
39
  if (miles < WHOLE_MILE_THRESHOLD) {
@@ -50,7 +52,7 @@ const formatDimension = (meters) => {
50
52
 
51
53
  /**
52
54
  * bounds: [[west, south], [east, north]]
53
- * Returns: "400m by 1.4 miles"
55
+ * Returns: "400 metres by 1.4 miles"
54
56
  */
55
57
  const getAreaDimensions = (bounds) => {
56
58
  let west, south, east, north
@@ -69,12 +71,12 @@ const getAreaDimensions = (bounds) => {
69
71
  }
70
72
 
71
73
  // Width: west <-> east at the southern latitude
72
- const widthMeters = getDistance([west, south], [east, south])
74
+ const widthMetres = getDistance([west, south], [east, south])
73
75
  // Height: south <-> north at the western longitude
74
- const heightMeters = getDistance([west, south], [west, north])
76
+ const heightMetres = getDistance([west, south], [west, north])
75
77
 
76
- const widthLabel = formatDimension(widthMeters)
77
- const heightLabel = formatDimension(heightMeters)
78
+ const widthLabel = formatDimension(widthMetres)
79
+ const heightLabel = formatDimension(heightMetres)
78
80
 
79
81
  return `${heightLabel} by ${widthLabel}`
80
82
  }
@@ -82,7 +84,7 @@ const getAreaDimensions = (bounds) => {
82
84
  /**
83
85
  * Generate a cardinal direction move description.
84
86
  * Only non-zero moves are announced.
85
- * Example: "north 400m", "east 750m", or "south 400m, west 750m"
87
+ * Example: "north 400 metres", "east 750 metres", or "south 400 metres, west 750 metres"
86
88
  */
87
89
  const getCardinalMove = (from, to) => {
88
90
  const [lng1, lat1] = from
@@ -94,74 +96,18 @@ const getCardinalMove = (from, to) => {
94
96
  const moves = []
95
97
 
96
98
  if (Math.abs(dLat) > 0.0001) { // threshold to ignore tiny movement
97
- const meters = Math.round(getDistance([lng1, lat1], [lng1, lat2]))
98
- moves.push(`${dLat > 0 ? 'north' : 'south'} ${formatDimension(meters)}`)
99
+ const metres = Math.round(getDistance([lng1, lat1], [lng1, lat2]))
100
+ moves.push(`${dLat > 0 ? 'north' : 'south'} ${formatDimension(metres)}`)
99
101
  }
100
102
 
101
103
  if (Math.abs(dLng) > 0.0001) {
102
- const meters = Math.round(getDistance([lng1, lat1], [lng2, lat1]))
103
- moves.push(`${dLng > 0 ? 'east' : 'west'} ${formatDimension(meters)}`)
104
+ const metres = Math.round(getDistance([lng1, lat1], [lng2, lat1]))
105
+ moves.push(`${dLng > 0 ? 'east' : 'west'} ${formatDimension(metres)}`)
104
106
  }
105
107
 
106
108
  return moves.join(', ')
107
109
  }
108
110
 
109
- /**
110
- * Find the index of the nearest pixel in a given cardinal direction.
111
- *
112
- * The function:
113
- * - Filters candidate points that lie in the specified direction
114
- * (up, down, left, right) relative to a start pixel.
115
- * - Selects the nearest valid candidate using Euclidean distance.
116
- * - Falls back to returning the start pixel index if no candidate exists.
117
- *
118
- * Example:
119
- * spatialNavigate('up', [100, 200], [[100, 100], [150, 250], [90, 180]])
120
- * → returns the index of [100, 100]
121
- *
122
- * @param {'ArrowUp'|'ArrowDown'|'ArrowLeft'|'ArrowRight'} direction - The direction to search.
123
- * @param {[number, number]} start - The starting pixel coordinate [x, y].
124
- * @param {Array<[number, number]>} pixels - Array of pixel coordinates.
125
- * @returns {number} Index of the closest pixel in the given direction.
126
- */
127
- const isInDirection = (direction, dx, dy) => {
128
- switch (direction) {
129
- case 'ArrowUp': return dy < 0 && Math.abs(dy) >= Math.abs(dx)
130
- case 'ArrowDown': return dy > 0 && Math.abs(dy) >= Math.abs(dx)
131
- case 'ArrowLeft': return dx < 0 && Math.abs(dx) > Math.abs(dy)
132
- case 'ArrowRight': return dx > 0 && Math.abs(dx) > Math.abs(dy)
133
- default: return false
134
- }
135
- }
136
-
137
- const spatialNavigate = (direction, start, pixels) => {
138
- const [sx, sy] = start
139
-
140
- // Direction filters
141
- const candidates = pixels.filter(([x, y]) =>
142
- (x !== sx || y !== sy) && isInDirection(direction, x - sx, y - sy)
143
- )
144
-
145
- if (!candidates.length) {
146
- return pixels.findIndex(p => p[0] === sx && p[1] === sy)
147
- }
148
-
149
- // Choose the closest by Euclidean distance
150
- let closestIndex = -1
151
- let minDist = Infinity
152
- candidates.forEach(c => {
153
- const dx = c[0] - sx
154
- const dy = c[1] - sy
155
- const dist = dx * dx + dy * dy // squared distance is enough
156
- if (dist < minDist) {
157
- minDist = dist
158
- closestIndex = pixels.indexOf(c)
159
- }
160
- })
161
-
162
- return closestIndex
163
- }
164
-
165
111
  const getResolution = (center, zoom) => {
166
112
  const EARTH_CIRCUMFERENCE = 40075016.686
167
113
  const TILE_SIZE = 512
@@ -239,7 +185,6 @@ export {
239
185
  getCardinalMove,
240
186
  getBboxFromGeoJSON,
241
187
  isGeometryObscured,
242
- spatialNavigate,
243
188
  getResolution,
244
189
  getPaddedBounds,
245
190
  formatDimension
@@ -12,18 +12,21 @@ jest.mock('@turf/bbox', () => jest.fn(() => [-1, 50, 1, 52]))
12
12
  describe('spatial utils', () => {
13
13
  test('formatDimension hits all branches', () => {
14
14
  // < 0.5 miles
15
- expect(spatial.formatDimension(500)).toMatch(/m$/)
15
+ expect(spatial.formatDimension(500)).toMatch(/metres?$/)
16
+
17
+ // Singular metre (exactly 1) - hits roundedMetres === 1 branch
18
+ expect(spatial.formatDimension(1)).toBe('1 metre')
16
19
 
17
20
  // Singular mile (exactly 1.0) - hits value === 1 branch
18
21
  expect(spatial.formatDimension(1609.344)).toBe('1 mile')
19
22
 
20
23
  // 5 miles
21
- const metersSmallMiles = 5 * 1609.344
22
- expect(spatial.formatDimension(metersSmallMiles)).toMatch(/^5\s*miles$/)
24
+ const metresSmallMiles = 5 * 1609.344
25
+ expect(spatial.formatDimension(metresSmallMiles)).toMatch(/^5\s*miles$/)
23
26
 
24
27
  // >= WHOLE_MILE_THRESHOLD
25
- const metersLarge = 15 * 1609.344
26
- expect(spatial.formatDimension(metersLarge)).toBe('15 miles')
28
+ const metresLarge = 15 * 1609.344
29
+ expect(spatial.formatDimension(metresLarge)).toBe('15 miles')
27
30
  })
28
31
 
29
32
  test('array bounds triggers all branches', () => {
@@ -50,33 +53,6 @@ describe('spatial utils', () => {
50
53
  expect(spatial.getCardinalMove([0, 0], [0.00001, 0.00001])).toBe('')
51
54
  })
52
55
 
53
- test('spatialNavigate all directions and fallback', () => {
54
- const pixels = [[0, 0], [0, -1], [1, 0], [0, 1], [-1, 0]]
55
- expect(spatial.spatialNavigate('ArrowUp', [0, 0], pixels)).toBe(1)
56
- expect(spatial.spatialNavigate('ArrowDown', [0, 0], pixels)).toBe(3)
57
- expect(spatial.spatialNavigate('ArrowLeft', [0, 0], pixels)).toBe(4)
58
- expect(spatial.spatialNavigate('ArrowRight', [0, 0], pixels)).toBe(2)
59
- expect(spatial.spatialNavigate('InvalidDir', [0, 0], pixels)).toBe(0)
60
- })
61
-
62
- test('spatialNavigate finds closer candidates (hits dist < minDist)', () => {
63
- const start = [0, 0]
64
- const pixels = [[0, 0], [10, 0], [2, 0]]
65
- expect(spatial.spatialNavigate('ArrowRight', start, pixels)).toBe(2)
66
- })
67
-
68
- test('spatialNavigate skips farther candidate (dist >= minDist false branch)', () => {
69
- // Closer candidate first → second candidate fails dist < minDist
70
- const pixels = [[0, 0], [2, 0], [10, 0]]
71
- expect(spatial.spatialNavigate('ArrowRight', [0, 0], pixels)).toBe(1)
72
- })
73
-
74
- test('spatialNavigate diagonal with dx>dy', () => {
75
- const start = [0, 0]
76
- const pixels = [[0, 0], [3, 1], [1, 0]] // dx>dy
77
- expect(spatial.spatialNavigate('ArrowRight', start, pixels)).toBe(2)
78
- })
79
-
80
56
  test('getResolution returns positive value', () => {
81
57
  expect(spatial.getResolution({ lat: 0 }, 1)).toBeGreaterThan(0)
82
58
  })
package/rollup.esm.mjs CHANGED
@@ -166,7 +166,8 @@ const createESMConfig = (entryPath, outDir, isCore = false, manualChunks = null,
166
166
  // (rootDir) → "css/index.css" — no ".." in the emitted fileName.
167
167
  postcss({
168
168
  extract: path.resolve(cssDir, 'index.css'),
169
- use: ['sass']
169
+ use: ['sass'],
170
+ minimize: true
170
171
  }),
171
172
 
172
173
  terser(),
@@ -5,6 +5,12 @@ set -e
5
5
  VERSION_PATTERN="^v([0-9]{1,}.[0-9]{1,}.[0-9]{1,})(-[0-9A-Za-z-].*)?$"
6
6
  PRE_RELEASE_PATTERN="(-[0-9A-Za-z-].*)$"
7
7
 
8
+ # Prerelease labels (case-insensitive) that share the "pre-release" npm dist-tag.
9
+ # Any other label (e.g. an experimental feature slug) gets isolated to its own
10
+ # dist-tag instead — see determine_release_tag(). Add to this list if another
11
+ # shared-channel convention (e.g. "rc") is adopted.
12
+ SHARED_PRERELEASE_LABELS=("alpha" "beta")
13
+
8
14
  validate_arguments() {
9
15
  if [ -z "$TAG_NAME" ]; then
10
16
  echo "ERROR: TAG_NAME is required"
@@ -52,7 +58,37 @@ validate_version_bump() {
52
58
  echo "✓ Version check passed"
53
59
  }
54
60
 
61
+ is_shared_prerelease_label() {
62
+ local candidate_lower
63
+ candidate_lower=$(echo "$1" | tr '[:upper:]' '[:lower:]')
64
+
65
+ local shared_label
66
+ for shared_label in "${SHARED_PRERELEASE_LABELS[@]}"; do
67
+ if [[ "$candidate_lower" == "$shared_label" ]]; then
68
+ return 0
69
+ fi
70
+ done
71
+ return 1
72
+ }
73
+
55
74
  determine_release_tag() {
75
+ # Extract the first prerelease identifier: semver splits identifiers on '.',
76
+ # so this stops at the first dot if there is one ("-fmp.2" -> "fmp"), or runs
77
+ # to the end of the string if there isn't ("-fmp-2" -> "fmp-2", since hyphens
78
+ # are legal inside a single identifier). Anything not in
79
+ # SHARED_PRERELEASE_LABELS (matched case-insensitively) gets isolated to its
80
+ # own npm dist-tag either way — this is what prevents a mistyped experimental
81
+ # tag from silently landing in the shared "pre-release" channel, regardless
82
+ # of whether a dot or hyphen was used before the build number, or how the
83
+ # label happens to be capitalised.
84
+ if [[ "$TAG_NAME" =~ -([^.]+) ]]; then
85
+ local label="${BASH_REMATCH[1]}"
86
+ if ! is_shared_prerelease_label "$label"; then
87
+ echo "$label"
88
+ return
89
+ fi
90
+ fi
91
+
56
92
  if [[ "$IS_PRE_RELEASE" == "true" ]] || [[ "$TAG_NAME" =~ $PRE_RELEASE_PATTERN ]]; then
57
93
  echo "pre-release"
58
94
  else
package/src/App/App.jsx CHANGED
@@ -19,7 +19,7 @@ export const App = (props) => {
19
19
  return (
20
20
  <AppProvider options={props}>
21
21
  <MapProvider options={props}>
22
- <ServiceProvider eventBus={props.eventBus}>
22
+ <ServiceProvider eventBus={props.eventBus} pluginRegistry={props.pluginRegistry}>
23
23
  <PluginProvider>
24
24
  <PluginInits />
25
25
  <Layout />
@@ -70,7 +70,8 @@ describe('App', () => {
70
70
  })
71
71
 
72
72
  test('renders component hierarchy with props', () => {
73
- const props = { mapProvider: 'test', extraProp: 'value', eventBus: mockEventBus }
73
+ const mockPluginRegistry = { getPlugin: jest.fn() }
74
+ const props = { mapProvider: 'test', extraProp: 'value', eventBus: mockEventBus, pluginRegistry: mockPluginRegistry }
74
75
 
75
76
  render(<App {...props} />)
76
77
 
@@ -98,6 +99,10 @@ describe('App', () => {
98
99
  // Verify eventBus exists
99
100
  expect(appProviderProps.options.eventBus).toBe(mockEventBus)
100
101
  expect(mapProviderProps.options.eventBus).toBe(mockEventBus)
102
+
103
+ // Verify pluginRegistry reaches ServiceProvider
104
+ const serviceProviderProps = ServiceProvider.mock.calls[0][0]
105
+ expect(serviceProviderProps.pluginRegistry).toBe(mockPluginRegistry)
101
106
  })
102
107
 
103
108
  test('calls removeLoadingState and emits APP_READY on mount', () => {
@@ -16,24 +16,38 @@ export const CrossHair = () => {
16
16
  const { isVisible, isPinnedToMap, state } = crossHair
17
17
 
18
18
  return (
19
- <svg
20
- id={`${id}-cross-hair`}
19
+ <button
20
+ tabIndex='-1'
21
21
  ref={crossHairRef}
22
- className='im-c-cross-hair'
23
- width='38'
24
- height='38'
25
- viewBox='0 0 38 38'
26
- fillRule='evenodd'
27
- fill='currentColor'
22
+ id={`${id}-cross-hair`}
23
+ className='im-c-cross-hair-button'
24
+ // "Target" — short for Voice Control to say ("Click Target"). A plain attribute, not a
25
+ // hidden child <span>, so there's no separate text node for Chrome's AX tree to expose.
26
+ aria-label='Target'
27
+ // This button should never take real DOM focus — a screen reader's virtual cursor and
28
+ // Voice Control can both reach it without that, and real focus landing here would break
29
+ // keyboard drawing (which requires focus to stay on the viewport container).
30
+ onMouseDown={(event) => event.preventDefault()}
31
+ onClick={() => crossHair.activate?.()}
28
32
  style={{
29
33
  position: 'absolute',
30
34
  left: isPinnedToMap ? 0 : '50%',
31
35
  top: isPinnedToMap ? 0 : '50%',
32
- pointerEvents: 'none',
33
36
  display: isVisible ? 'block' : 'none'
34
37
  }}
35
38
  >
36
- <path d={paths[state || 'active']} />
37
- </svg>
39
+ {/* aria-hidden: without it, WebKit exposes the <path> as its own accessibility node, and
40
+ Voice Control activates that instead of bubbling a click up to this button. */}
41
+ <svg
42
+ width='38'
43
+ height='38'
44
+ viewBox='0 0 38 38'
45
+ fillRule='evenodd'
46
+ fill='currentColor'
47
+ aria-hidden='true'
48
+ >
49
+ <path d={paths[state || 'active']} />
50
+ </svg>
51
+ </button>
38
52
  )
39
53
  }
@@ -1,14 +1,39 @@
1
- .im-c-cross-hair {
2
- position: absolute;
3
- top: 0;
4
- left: 0;
5
- pointer-events: none;
1
+ // A real <button> (tabIndex="-1") so it's a discoverable target for AT (Voice Control, a screen
2
+ // reader's virtual cursor) without being a Tab stop or ever taking real DOM focus.
3
+ .im-c-cross-hair-button {
4
+ // Its ancestor (.im-c-viewport__safezone) is pointer-events:none, so this has to opt back in
5
+ // itself or clicks here pass straight through to the map canvas underneath.
6
+ pointer-events: auto;
7
+
8
+ // Reset native chrome to zero — position/margin below assumes the content-box starts exactly
9
+ // at the border-box edge.
10
+ appearance: none;
11
+ border: 0;
12
+ background: none;
13
+ padding: 0;
14
+ font: inherit;
15
+ cursor: inherit;
16
+
17
+ // Explicit size plus the negative margin centre the button's own geometry (not just its SVG
18
+ // child's) on wherever CrossHair.jsx's inline top/left positions it — without it, an AT
19
+ // overlay's badge would land at the anchor point's corner, not on the icon.
20
+ width: 38px;
21
+ height: 38px;
6
22
  margin: -19px 0 0 -19px;
7
- }
8
23
 
9
- .im-c-cross-hair path {
10
- stroke-width: 3;
11
- stroke: var(--map-overlay-halo-color);
12
- fill: var(--map-overlay-foreground-color);
13
- paint-order: stroke fill;
14
- }
24
+ outline: 3px solid transparent;
25
+
26
+ svg {
27
+ display: block;
28
+ // Purely decorative (aria-hidden in CrossHair.jsx) — without this it's still its own
29
+ // hit-testable target, so a click could resolve to the icon instead of the button.
30
+ pointer-events: none;
31
+ }
32
+
33
+ path {
34
+ stroke-width: 3;
35
+ stroke: var(--map-overlay-halo-color);
36
+ fill: var(--map-overlay-foreground-color);
37
+ paint-order: stroke fill;
38
+ }
39
+ }
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { render } from '@testing-library/react'
2
+ import { render, fireEvent } from '@testing-library/react'
3
3
  import { CrossHair } from './CrossHair.jsx'
4
4
  import { useCrossHair } from '../../hooks/useCrossHairAPI.js'
5
5
 
@@ -9,6 +9,8 @@ jest.mock('../../store/configContext', () => ({ useConfig: jest.fn(() => ({ id:
9
9
  describe('CrossHair', () => {
10
10
  const crossHairRef = React.createRef()
11
11
 
12
+ // Positioning/visibility/sizing all live on the outer <button> (tabIndex="-1", discoverable
13
+ // by AT like Voice Control without being a Tab stop); the SVG itself is a plain, unstyled child.
12
14
  const renderWith = (overrides = {}) => {
13
15
  useCrossHair.mockReturnValue({
14
16
  crossHairRef,
@@ -19,43 +21,70 @@ describe('CrossHair', () => {
19
21
  ...overrides
20
22
  }
21
23
  })
22
- return render(<CrossHair />).container.querySelector('svg')
24
+ const { container } = render(<CrossHair />)
25
+ return { button: container.querySelector('button'), svg: container.querySelector('svg') }
23
26
  }
24
27
 
25
28
  it('renders visible active marker when pinned', () => {
26
- const svg = renderWith()
29
+ const { button, svg } = renderWith()
27
30
  const path = svg.querySelector('path')
28
31
 
29
- expect(svg).toHaveClass('im-c-cross-hair')
30
- expect(svg).toHaveStyle({
32
+ expect(button).toHaveClass('im-c-cross-hair-button')
33
+ expect(button).toHaveStyle({
31
34
  position: 'absolute',
32
35
  left: 0,
33
36
  top: 0,
34
- pointerEvents: 'none',
35
37
  display: 'block'
36
38
  })
37
39
  expect(path.getAttribute('d')).toContain('M5.035 20H1v-2h4.035C5.525') // active path
38
40
  })
39
41
 
40
42
  it('positions marker at center when not pinned', () => {
41
- const svg = renderWith({ isPinnedToMap: false })
43
+ const { button } = renderWith({ isPinnedToMap: false })
42
44
 
43
- expect(svg).toHaveStyle({
45
+ expect(button).toHaveStyle({
44
46
  left: '50%',
45
47
  top: '50%'
46
48
  })
47
49
  })
48
50
 
51
+ // Fully removed from the layout/accessibility tree when hidden, not just faded — so Voice
52
+ // Control's Show Names never puts a "Target" label over nothing. Its real route back is
53
+ // opening MapControls (a normal, always-visible button), not re-discovering this element.
49
54
  it('hides marker when not visible', () => {
50
- const svg = renderWith({ isVisible: false })
51
- expect(svg).toHaveStyle({ display: 'none' })
55
+ const { button } = renderWith({ isVisible: false })
56
+ expect(button).toHaveStyle({ display: 'none' })
57
+ })
58
+
59
+ it('invokes crossHair.activate() on click, for Voice Control / mouse / touch activation', () => {
60
+ const activate = jest.fn()
61
+ const { button } = renderWith({ activate })
62
+ fireEvent.click(button)
63
+ expect(activate).toHaveBeenCalledTimes(1)
64
+ })
65
+
66
+ it('does not throw when clicked with no activate() assigned', () => {
67
+ const { button } = renderWith()
68
+ expect(() => fireEvent.click(button)).not.toThrow()
69
+ })
70
+
71
+ it('prevents the default mousedown so a real click never gives it focus (or the resulting outline)', () => {
72
+ const { button } = renderWith()
73
+ const event = fireEvent.mouseDown(button)
74
+ expect(event).toBe(false) // fireEvent returns false when preventDefault() was called
75
+ })
76
+
77
+ it('is not a Tab stop, but is a real button for coordinate-based AT (e.g. Voice Control) to find', () => {
78
+ const { button } = renderWith()
79
+ expect(button.tabIndex).toBe(-1)
80
+ expect(button.tagName).toBe('BUTTON')
52
81
  })
53
82
 
54
83
  it.each([
55
84
  ['inactive', 'M5.035 20H1v-2h4.035a13.98'],
56
85
  [undefined, 'M5.035 20H1v-2h4.035C5.525'] // fallback to active
57
86
  ])('renders correct path for state=%s', (state, expected) => {
58
- const svg = renderWith({ state })
87
+ const { svg } = renderWith({ state })
59
88
  expect(svg.querySelector('path').getAttribute('d')).toContain(expected)
60
89
  })
61
90
  })
@@ -17,7 +17,7 @@ import { htmlToPlainText } from '../../../utils/htmlToPlainText.js'
17
17
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
18
18
  // sonarjs/disable-next-line function-name
19
19
  export const Hints = () => {
20
- const { id, keyboardHintText, enableMoveControls, moveControlsHintText } = useConfig()
20
+ const { id, keyboardHintText, enableMapControls, mapControlsHintText } = useConfig()
21
21
  const { hints } = useService()
22
22
  const { layoutRefs } = useApp()
23
23
  const [activeHint, setActiveHint] = useState(null)
@@ -33,8 +33,8 @@ export const Hints = () => {
33
33
  }
34
34
 
35
35
  // Composed at render time so overriding one text doesn't drop the other.
36
- const keyboardDescriptionHtml = enableMoveControls
37
- ? `${keyboardHintText} ${moveControlsHintText}`
36
+ const keyboardDescriptionHtml = enableMapControls
37
+ ? `${keyboardHintText} ${mapControlsHintText}`
38
38
  : keyboardHintText
39
39
 
40
40
  return createPortal(
@@ -52,13 +52,13 @@ describe('Hints — rendering', () => {
52
52
  expect(desc.innerHTML).toBe('Shift + ?')
53
53
  })
54
54
 
55
- it('appends the move-controls hint text when enableMoveControls is true', () => {
55
+ it('appends the map-controls hint text when enableMapControls is true', () => {
56
56
  const mainEl = setup()
57
57
  useConfig.mockReturnValue({
58
58
  id: 'test-map',
59
59
  keyboardHintText: '<kbd>Shift</kbd> + <kbd>?</kbd>',
60
- enableMoveControls: true,
61
- moveControlsHintText: '<kbd>Arrow keys</kbd> to move'
60
+ enableMapControls: true,
61
+ mapControlsHintText: '<kbd>Arrow keys</kbd> to move'
62
62
  })
63
63
  render(<Hints />)
64
64
  const desc = mainEl.querySelector('#test-map-keyboard-desc')
@@ -10,7 +10,10 @@ const DEFAULT_GROUP = 'Navigate'
10
10
  const ShortcutList = ({ items }) => (
11
11
  <dl className='im-c-keyboard-help__list'>
12
12
  {items.map((item) => (
13
- <div key={item.id} className='im-c-keyboard-help__item'>
13
+ <div
14
+ key={item.id}
15
+ className={`im-c-keyboard-help__item${item.visuallyHidden ? ' im-u-visually-hidden' : ''}`}
16
+ >
14
17
  <dt className='im-c-keyboard-help__title'>{item.title}</dt>
15
18
  <dd className='im-c-keyboard-help__description' dangerouslySetInnerHTML={{ __html: item.command }} />
16
19
  </div>
@@ -68,6 +68,23 @@ describe('KeyboardHelp — flat list', () => {
68
68
  render(<KeyboardHelp />)
69
69
  expect(screen.getByText('↑')).toBeInTheDocument()
70
70
  })
71
+
72
+ it('visually hides a shortcut flagged visuallyHidden but keeps it in the DOM for assistive tech', () => {
73
+ getKeyboardShortcuts.mockReturnValue([
74
+ ...VIEWPORT_SHORTCUTS,
75
+ { id: 'sr', context: 'viewport', title: 'Get info', command: '<kbd>Alt</kbd> + <kbd>I</kbd>', visuallyHidden: true }
76
+ ])
77
+ render(<KeyboardHelp />)
78
+ const item = screen.getByText('Get info').closest('.im-c-keyboard-help__item')
79
+ expect(item).toHaveClass('im-u-visually-hidden')
80
+ })
81
+
82
+ it('does not visually hide a regular shortcut', () => {
83
+ getKeyboardShortcuts.mockReturnValue(VIEWPORT_SHORTCUTS)
84
+ render(<KeyboardHelp />)
85
+ const item = screen.getByText('Move').closest('.im-c-keyboard-help__item')
86
+ expect(item).not.toHaveClass('im-u-visually-hidden')
87
+ })
71
88
  })
72
89
 
73
90
  // ─── tab UI (multiple groups) ─────────────────────────────────────────────────