@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
@@ -10,7 +10,7 @@ import { useMediaQueryDispatch } from '../hooks/useMediaQueryDispatch.js'
10
10
  export const AppContext = createContext(null)
11
11
 
12
12
  export const AppProvider = ({ options, children }) => {
13
- const { pluginRegistry, buttonRegistry, panelRegistry, controlRegistry, keyboardShortcutRegistry, eventBus, breakpointDetector } = options
13
+ const { pluginRegistry, buttonRegistry, panelRegistry, controlRegistry, keyboardShortcutRegistry, spatialListRegistry, eventBus, breakpointDetector } = options
14
14
 
15
15
  const layoutRefs = {
16
16
  appContainerRef: useRef(null),
@@ -96,7 +96,8 @@ export const AppProvider = ({ options, children }) => {
96
96
  buttonRegistry,
97
97
  panelRegistry,
98
98
  controlRegistry,
99
- keyboardShortcutRegistry
99
+ keyboardShortcutRegistry,
100
+ spatialListRegistry
100
101
  }), [state, dispatch])
101
102
 
102
103
  return (
@@ -10,7 +10,7 @@ import { patternRegistry } from '../../services/patternRegistry.js'
10
10
 
11
11
  export const ServiceContext = createContext(null)
12
12
 
13
- export const ServiceProvider = ({ eventBus, children }) => {
13
+ export const ServiceProvider = ({ eventBus, pluginRegistry, children }) => {
14
14
  const { id, handleExitClick, symbolDefaults: constructorSymbolDefaults } = useConfig()
15
15
  const mapStatusRef = useRef(null)
16
16
  const announce = useMemo(() => createAnnouncer(mapStatusRef), [])
@@ -26,8 +26,10 @@ export const ServiceProvider = ({ eventBus, children }) => {
26
26
  mapStatusRef,
27
27
  closeApp: () => closeApp(id, handleExitClick, eventBus),
28
28
  symbolRegistry,
29
- patternRegistry
30
- }), [announce, hints])
29
+ patternRegistry,
30
+ // See pluginRegistry.js's getPlugin() for what this does and doesn't guarantee.
31
+ getPlugin: (pluginId) => pluginRegistry?.getPlugin(pluginId)
32
+ }), [announce, hints, pluginRegistry])
31
33
 
32
34
  return (
33
35
  <ServiceContext.Provider value={services}>
@@ -34,7 +34,10 @@ jest.mock('../store/configContext.js', () => ({
34
34
 
35
35
  describe('ServiceProvider', () => {
36
36
  const mockEventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
37
- const wrapper = ({ children }) => <ServiceProvider eventBus={mockEventBus}>{children}</ServiceProvider>
37
+ const mockPluginRegistry = { getPlugin: jest.fn(() => ({ newPolygon: jest.fn() })) }
38
+ const wrapper = ({ children }) => (
39
+ <ServiceProvider eventBus={mockEventBus} pluginRegistry={mockPluginRegistry}>{children}</ServiceProvider>
40
+ )
38
41
 
39
42
  test('provides announce, reverseGeocode, eventBus, and closeApp via context', () => {
40
43
  const { result } = renderHook(() => React.useContext(ServiceContext), { wrapper })
@@ -68,6 +71,22 @@ describe('ServiceProvider', () => {
68
71
  expect(mockShow).toHaveBeenCalledWith('<b>test</b>', { duration: 2000 })
69
72
  })
70
73
 
74
+ test('getPlugin delegates to pluginRegistry.getPlugin', () => {
75
+ const { result } = renderHook(() => React.useContext(ServiceContext), { wrapper })
76
+
77
+ const plugin = result.current.getPlugin('draw')
78
+
79
+ expect(mockPluginRegistry.getPlugin).toHaveBeenCalledWith('draw')
80
+ expect(plugin).toEqual({ newPolygon: expect.any(Function) })
81
+ })
82
+
83
+ test('getPlugin returns undefined when no pluginRegistry is provided', () => {
84
+ const noRegistryWrapper = ({ children }) => <ServiceProvider eventBus={mockEventBus}>{children}</ServiceProvider>
85
+ const { result } = renderHook(() => React.useContext(ServiceContext), { wrapper: noRegistryWrapper })
86
+
87
+ expect(result.current.getPlugin('draw')).toBeUndefined()
88
+ })
89
+
71
90
  test('closeApp calls closeApp service with id and handleExitClick', () => {
72
91
  const mockHandleExitClick = jest.fn()
73
92
  const { useConfig } = require('../store/configContext.js')
@@ -461,12 +461,23 @@ export default class InteractiveMap {
461
461
  * Focus is moved to the panel by default. Set `focus: false` in options to
462
462
  * suppress this — useful when showing a panel and you want focus to remain on the button.
463
463
  *
464
+ * When the panel is later closed, focus returns to `triggeringElement` if one is given or
465
+ * can be inferred, falling back to the map viewport otherwise. If `triggeringElement` isn't
466
+ * passed explicitly, whatever currently has real DOM focus is captured automatically (e.g. a
467
+ * selected item in the map features list) — this only needs to be passed explicitly when the
468
+ * panel is opened from something that doesn't hold focus at the time (e.g a timer, or a
469
+ * genuine mouse click, which doesn't reliably focus its target in every browser).
470
+ *
464
471
  * @param {string} id - Panel identifier to show.
465
472
  * @param {object} [options]
466
473
  * @param {boolean} [options.focus=true] - Whether to move focus to the panel.
474
+ * @param {HTMLElement} [options.triggeringElement] - Element to return focus to on close.
475
+ * Defaults to the currently focused element, if any.
467
476
  */
468
- showPanel (id, { focus = true } = {}) {
469
- this.eventBus.emit(events.APP_SHOW_PANEL, { id, focus })
477
+ showPanel (id, { focus = true, triggeringElement } = {}) {
478
+ const resolvedTriggeringElement = triggeringElement ??
479
+ (document.activeElement && document.activeElement !== document.body ? document.activeElement : undefined)
480
+ this.eventBus.emit(events.APP_SHOW_PANEL, { id, focus, triggeringElement: resolvedTriggeringElement })
470
481
  }
471
482
 
472
483
  /**
@@ -580,6 +580,39 @@ describe('InteractiveMap — Public API Methods', () => {
580
580
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'btn-1', prop: 'disabled', value: true })
581
581
  })
582
582
 
583
+ describe('showPanel — triggeringElement', () => {
584
+ afterEach(() => { document.body.innerHTML = '' })
585
+
586
+ it('passes an explicit triggeringElement through unchanged', () => {
587
+ const el = document.createElement('button')
588
+ map.showPanel('panel1', { triggeringElement: el })
589
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: el })
590
+ })
591
+
592
+ it('auto-captures the currently focused element when none is given', () => {
593
+ const el = document.createElement('button')
594
+ document.body.appendChild(el)
595
+ el.focus()
596
+ map.showPanel('panel1')
597
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: el })
598
+ })
599
+
600
+ it('does not fall back to document.body as a triggeringElement', () => {
601
+ document.body.focus?.() // no-op in jsdom, but document.activeElement defaults to body regardless
602
+ map.showPanel('panel1')
603
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: undefined })
604
+ })
605
+
606
+ it('an explicit triggeringElement takes priority over the currently focused element', () => {
607
+ const focused = document.createElement('button')
608
+ document.body.appendChild(focused)
609
+ focused.focus()
610
+ const explicit = document.createElement('button')
611
+ map.showPanel('panel1', { triggeringElement: explicit })
612
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel1', focus: true, triggeringElement: explicit })
613
+ })
614
+ })
615
+
583
616
  it('setContinueEnabled enables and disables the journey continue button', () => {
584
617
  map.setContinueEnabled(true)
585
618
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'journeyContinue', prop: 'disabled', value: false })
@@ -1,5 +1,5 @@
1
1
  import { KeyboardHelp } from '../App/components/KeyboardHelp/KeyboardHelp.jsx'
2
- import { MoveControls } from '../App/components/MoveControls/MoveControls.jsx'
2
+ import { MapControls } from '../App/components/MapControls/MapControls.jsx'
3
3
 
4
4
  const keyboardBasePanelSlots = {
5
5
  slot: 'middle',
@@ -13,7 +13,7 @@ const buttonSlots = {
13
13
  showLabel: false
14
14
  }
15
15
 
16
- const moveControlsSlot = {
16
+ const mapControlsSlot = {
17
17
  slot: 'right-bottom'
18
18
  }
19
19
 
@@ -53,7 +53,7 @@ export const defaultAppConfig = {
53
53
  desktop: journeyContinueSlots
54
54
  }, {
55
55
  id: 'exit',
56
- label: 'Exit',
56
+ label: 'Close map view',
57
57
  iconId: 'close',
58
58
  onClick: (_e, { services }) => services.closeApp(),
59
59
  excludeWhen: ({ appConfig, appState }) => !appConfig.hasExitButton || !appState.isFullscreen,
@@ -79,7 +79,7 @@ export const defaultAppConfig = {
79
79
  iconId: 'plus',
80
80
  keepFocus: true,
81
81
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta),
82
- excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControls || appState.interfaceType === 'touch',
82
+ excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMapControls || appState.interfaceType === 'touch',
83
83
  enableWhen: ({ mapState }) => !mapState.isAtMaxZoom,
84
84
  mobile: buttonSlots,
85
85
  tablet: buttonSlots,
@@ -91,23 +91,23 @@ export const defaultAppConfig = {
91
91
  iconId: 'minus',
92
92
  keepFocus: true,
93
93
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta),
94
- excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControls || appState.interfaceType === 'touch',
94
+ excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMapControls || appState.interfaceType === 'touch',
95
95
  enableWhen: ({ mapState }) => !mapState.isAtMinZoom,
96
96
  mobile: buttonSlots,
97
97
  tablet: buttonSlots,
98
98
  desktop: buttonSlots
99
99
  }, {
100
- id: 'moveControls',
101
- label: 'Move and zoom controls',
100
+ id: 'mapControls',
101
+ label: 'Map controls',
102
102
  iconId: 'move',
103
103
  keepFocus: true,
104
104
  isExpanded: false,
105
- ariaControls: ({ appConfig }) => `${appConfig.id}-move-controls-content`,
105
+ ariaControls: ({ appConfig }) => `${appConfig.id}-map-controls-content`,
106
106
  onClick: (_e, { appState }) => appState.dispatch({
107
107
  type: 'TOGGLE_BUTTON_EXPANDED',
108
- payload: { id: 'moveControls', isExpanded: !appState.expandedButtons.has('moveControls') }
108
+ payload: { id: 'mapControls', isExpanded: !appState.expandedButtons.has('mapControls') }
109
109
  }),
110
- excludeWhen: ({ appConfig }) => !appConfig.enableMoveControls,
110
+ excludeWhen: ({ appConfig }) => !appConfig.enableMapControls,
111
111
  mobile: buttonSlots,
112
112
  tablet: buttonSlots,
113
113
  desktop: buttonSlots
@@ -131,13 +131,13 @@ export const defaultAppConfig = {
131
131
  }],
132
132
 
133
133
  controls: [{
134
- id: 'moveControls',
134
+ id: 'mapControls',
135
135
  label: 'Move and zoom',
136
- excludeWhen: ({ appConfig }) => !appConfig.enableMoveControls,
137
- mobile: moveControlsSlot,
138
- tablet: moveControlsSlot,
139
- desktop: moveControlsSlot,
140
- render: MoveControls
136
+ excludeWhen: ({ appConfig }) => !appConfig.enableMapControls,
137
+ mobile: mapControlsSlot,
138
+ tablet: mapControlsSlot,
139
+ desktop: mapControlsSlot,
140
+ render: MapControls
141
141
  }],
142
142
 
143
143
  icons: [{
@@ -157,7 +157,7 @@ export const defaultAppConfig = {
157
157
  svgContent: '<path d="m6 9 6 6 6-6"/>'
158
158
  }, {
159
159
  id: 'move',
160
- svgContent: '<path d="M12 2v20"/><path d="m15 19-3 3-3-3"/><path d="m19 9 3 3-3 3"/><path d="M2 12h20"/><path d="m5 9-3 3 3 3"/><path d="m9 5 3-3 3 3"/>'
160
+ svgContent: '<path d="M11.146 15.854a1.207 1.207 0 0 1 1.708 0l1.56 1.56A2 2 0 0 1 15 18.828V21a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-2.172a2 2 0 0 1 .586-1.414z"/><path d="M18.828 15a2 2 0 0 1-1.414-.586l-1.56-1.56a1.207 1.207 0 0 1 0-1.708l1.56-1.56A2 2 0 0 1 18.828 9H21a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1z"/><path d="M6.586 14.414A2 2 0 0 1 5.172 15H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2.172a2 2 0 0 1 1.414.586l1.56 1.56a1.207 1.207 0 0 1 0 1.708z"/><path d="M9 3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2.172a2 2 0 0 1-.586 1.414l-1.56 1.56a1.207 1.207 0 0 1-1.708 0l-1.56-1.56A2 2 0 0 1 9 5.172z"/>'
161
161
  }, {
162
162
  id: 'precision',
163
163
  svgContent: '<circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/>'
@@ -19,7 +19,7 @@ describe('defaultAppConfig', () => {
19
19
  const journeyContinueBtn = buttons.find(b => b.id === 'journeyContinue')
20
20
  const zoomInBtn = buttons.find(b => b.id === 'zoomIn')
21
21
  const zoomOutBtn = buttons.find(b => b.id === 'zoomOut')
22
- const moveControlsBtn = buttons.find(b => b.id === 'moveControls')
22
+ const mapControlsBtn = buttons.find(b => b.id === 'mapControls')
23
23
 
24
24
  // --- UI RENDER TESTS ---
25
25
  it('renders KeyboardHelp panel', () => {
@@ -144,21 +144,21 @@ describe('defaultAppConfig', () => {
144
144
  appState: { interfaceType: 'mouse' }
145
145
  })).toBe(true)
146
146
 
147
- // Branch B: Second part of OR is true (enableMoveControls)
147
+ // Branch B: Second part of OR is true (enableMapControls)
148
148
  expect(btn.excludeWhen({
149
- appConfig: { enableZoomControls: true, enableMoveControls: true },
149
+ appConfig: { enableZoomControls: true, enableMapControls: true },
150
150
  appState: { interfaceType: 'mouse' }
151
151
  })).toBe(true)
152
152
 
153
153
  // Branch C: Third part of OR is true (interfaceType === 'touch')
154
154
  expect(btn.excludeWhen({
155
- appConfig: { enableZoomControls: true, enableMoveControls: false },
155
+ appConfig: { enableZoomControls: true, enableMapControls: false },
156
156
  appState: { interfaceType: 'touch' }
157
157
  })).toBe(true)
158
158
 
159
159
  // Branch D: All parts are false (Result: false)
160
160
  expect(btn.excludeWhen({
161
- appConfig: { enableZoomControls: true, enableMoveControls: false },
161
+ appConfig: { enableZoomControls: true, enableMapControls: false },
162
162
  appState: { interfaceType: 'mouse' }
163
163
  })).toBe(false)
164
164
  })
@@ -183,37 +183,37 @@ describe('defaultAppConfig', () => {
183
183
  })
184
184
 
185
185
  // --- MOVE CONTROLS TOGGLE BUTTON ---
186
- it('covers all branches of moveControls excludeWhen', () => {
187
- expect(moveControlsBtn.excludeWhen({ appConfig: { enableMoveControls: false } })).toBe(true)
188
- expect(moveControlsBtn.excludeWhen({ appConfig: { enableMoveControls: true } })).toBe(false)
186
+ it('covers all branches of mapControls excludeWhen', () => {
187
+ expect(mapControlsBtn.excludeWhen({ appConfig: { enableMapControls: false } })).toBe(true)
188
+ expect(mapControlsBtn.excludeWhen({ appConfig: { enableMapControls: true } })).toBe(false)
189
189
  })
190
190
 
191
- it('moveControls ariaControls resolves the control element id', () => {
192
- expect(moveControlsBtn.ariaControls({ appConfig: { id: 'im' } })).toBe('im-move-controls-content')
191
+ it('mapControls ariaControls resolves the control element id', () => {
192
+ expect(mapControlsBtn.ariaControls({ appConfig: { id: 'im' } })).toBe('im-map-controls-content')
193
193
  })
194
194
 
195
- it('moveControls onClick toggles TOGGLE_BUTTON_EXPANDED based on current state', () => {
195
+ it('mapControls onClick toggles TOGGLE_BUTTON_EXPANDED based on current state', () => {
196
196
  const dispatch = jest.fn()
197
197
 
198
- moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set() } })
198
+ mapControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set() } })
199
199
  expect(dispatch).toHaveBeenCalledWith({
200
200
  type: 'TOGGLE_BUTTON_EXPANDED',
201
- payload: { id: 'moveControls', isExpanded: true }
201
+ payload: { id: 'mapControls', isExpanded: true }
202
202
  })
203
203
 
204
204
  dispatch.mockClear()
205
- moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set(['moveControls']) } })
205
+ mapControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set(['mapControls']) } })
206
206
  expect(dispatch).toHaveBeenCalledWith({
207
207
  type: 'TOGGLE_BUTTON_EXPANDED',
208
- payload: { id: 'moveControls', isExpanded: false }
208
+ payload: { id: 'mapControls', isExpanded: false }
209
209
  })
210
210
  })
211
211
 
212
212
  // --- MOVE CONTROLS ---
213
- it('covers all branches of moveControls control excludeWhen', () => {
214
- const control = defaultAppConfig.controls.find(c => c.id === 'moveControls')
215
- expect(control.excludeWhen({ appConfig: { enableMoveControls: false } })).toBe(true)
216
- expect(control.excludeWhen({ appConfig: { enableMoveControls: true } })).toBe(false)
213
+ it('covers all branches of mapControls control excludeWhen', () => {
214
+ const control = defaultAppConfig.controls.find(c => c.id === 'mapControls')
215
+ expect(control.excludeWhen({ appConfig: { enableMapControls: false } })).toBe(true)
216
+ expect(control.excludeWhen({ appConfig: { enableMapControls: true } })).toBe(false)
217
217
  })
218
218
 
219
219
  // --- SUPPLEMENTARY CONFIGS ---
@@ -16,7 +16,7 @@ const defaults = {
16
16
  containerHeight: '600px',
17
17
  deviceNotSupportedText: 'Your device is not supported. A map is available with a more up-to-date browser or device.',
18
18
  enableFullscreen: false,
19
- enableMoveControls: true,
19
+ enableMapControls: true,
20
20
  enableZoomControls: true,
21
21
  genericErrorText: 'There was a problem loading the map. Please try again later.',
22
22
  hasExitButton: false,
@@ -32,7 +32,7 @@ const defaults = {
32
32
  urlPosition: 'sync',
33
33
  maxMobileWidth: 640,
34
34
  minDesktopWidth: 835,
35
- moveControlsHintText: 'A "Move and zoom controls" button is also available, opening further controls for moving and zooming.',
35
+ mapControlsHintText: 'A "Move and zoom controls" button is also available, opening further controls for moving and zooming.',
36
36
  nudgePanDelta: 5,
37
37
  nudgeZoomDelta: 0.1,
38
38
  panDelta: 100,
@@ -145,14 +145,14 @@ export const EVENTS = {
145
145
  MAP_SET_STYLE: 'map:setstyle',
146
146
  /** @internal Set map size. Payload: { width, height } */
147
147
  MAP_SET_SIZE: 'map:setsize',
148
- /** @internal Set the accessible features list. Payload: { items: { id: string, label: string }[] } */
149
- MAP_SET_FEATURES: 'map:setfeatures',
150
- /** @internal Suppress/restore the accessible features list. Payload: { suppressed: boolean } */
151
- MAP_SET_FEATURES_SUPPRESSED: 'map:setfeaturessuppressed',
152
- /** @internal Set the active feature in the accessible features list. Payload: { id: string | null } */
153
- MAP_SET_ACTIVE_FEATURE: 'map:setactivefeature',
154
- /** @internal Select the active listbox feature as the real selection (Enter key). Payload: none */
155
- MAP_SELECT_FEATURE: 'map:selectfeature',
148
+ /** @internal Set the accessible spatial list's items. Payload: { items: { id: string, label: string }[], multiselectable: boolean, label?: string } */
149
+ MAP_SET_SPATIAL_LIST: 'map:setspatiallist',
150
+ /** @internal Suppress/restore the accessible spatial list. Payload: { suppressed: boolean } */
151
+ MAP_SET_SPATIAL_LIST_SUPPRESSED: 'map:setspatiallistsuppressed',
152
+ /** @internal Set the active item in the accessible spatial list (roving tabindex position). Payload: { id: string | null } */
153
+ MAP_SET_ACTIVE_ITEM: 'map:setactiveitem',
154
+ /** @internal Select the active spatial list item as the real selection (Enter key). Payload: none */
155
+ MAP_SELECT_ITEM: 'map:selectitem',
156
156
  /** @internal Set pixel ratio. Payload: pixelRatio */
157
157
  MAP_SET_PIXEL_RATIO: 'map:setpixelratio',
158
158
  /** @internal Fit the map to a bounding box. Payload: [west, south, east, north] */
@@ -29,9 +29,9 @@
29
29
  @use '../App/components/Hints/Hints.module';
30
30
  @use '../App/components/CrossHair/CrossHair.module';
31
31
  @use '../App/components/Markers/Markers.module';
32
- @use '../App/components/Viewport/Features.module';
32
+ @use '../App/components/Viewport/SpatialList.module';
33
33
  @use '../App/components/Actions/Actions.module';
34
- @use '../App/components/MoveControls/MoveControls.module';
34
+ @use '../App/components/MapControls/MapControls.module';
35
35
 
36
36
  // Components that dont have a corresponding React Component
37
37
  @use 'components';
@@ -0,0 +1,29 @@
1
+ // ===================================================
2
+ // Tool: govuk-content-fallback-colors
3
+ // ===================================================
4
+ // Resets GOV.UK Frontend's own text-colour classes back onto our theme tokens, since they set
5
+ // colour explicitly rather than inheriting it. !important wins against a third-party stylesheet
6
+ // whose specificity and load order we don't control. Deliberately-coloured components (tags,
7
+ // error/warning text, buttons, .govuk-link--inverse/--text-colour/--muted) are left untouched so
8
+ // their built-in contrast pairing isn't broken.
9
+ // stylelint-disable declaration-no-important -- resetting a third-party stylesheet's own colours, see comment above
10
+ @mixin govuk-content-fallback-colors {
11
+ :is(
12
+ .govuk-body, .govuk-body-l, .govuk-body-m, .govuk-body-s, .govuk-body-xs,
13
+ .govuk-heading-xl, .govuk-heading-l, .govuk-heading-m, .govuk-heading-s,
14
+ .govuk-list, .govuk-label, .govuk-fieldset__legend,
15
+ .govuk-table, .govuk-table__cell, .govuk-table__header
16
+ ) {
17
+ color: var(--foreground-color) !important;
18
+ }
19
+
20
+ // GOV.UK Frontend renders these as its muted grey, matching our --secondary-text-color.
21
+ :is(.govuk-hint, .govuk-caption-xl, .govuk-caption-l, .govuk-caption-m, .govuk-caption-s) {
22
+ color: var(--secondary-text-color) !important;
23
+ }
24
+
25
+ .govuk-link:is(:link, :visited) {
26
+ color: var(--content-link-color) !important;
27
+ }
28
+ }
29
+ // stylelint-enable declaration-no-important
@@ -1,2 +1,3 @@
1
1
  @forward 'border-focus';
2
- @forward 'link-focus';
2
+ @forward 'link-focus';
3
+ @forward 'govuk-content';
package/src/test-utils.js CHANGED
@@ -37,6 +37,14 @@ export const createMockRegistries = (overrides = {}) => {
37
37
  registerKeyboardShortcut: jest.fn(),
38
38
  setProviderSupportedShortcuts: jest.fn(),
39
39
  getKeyboardShortcuts: jest.fn(() => [])
40
+ },
41
+ spatialListRegistry: {
42
+ registerItemProvider: jest.fn(),
43
+ unregisterItemProvider: jest.fn(),
44
+ claimExclusive: jest.fn(),
45
+ releaseExclusive: jest.fn(),
46
+ notifyItemsChanged: jest.fn(),
47
+ clear: jest.fn()
40
48
  }
41
49
  }
42
50
  }
package/src/types.js CHANGED
@@ -306,10 +306,10 @@
306
306
  * Get the dimensions of the visible map area as a formatted string (e.g., '400m by 750m').
307
307
  *
308
308
  * @property {(from: [number, number], to: [number, number]) => string} getCardinalMove
309
- * Get cardinal direction and distance between two coordinates ([lng, lat] or [easting, northing] depending on the crs of the map provider). Returns a formatted string (e.g., 'north 400m' or 'south 400m, west 750m').
309
+ * Get cardinal direction and distance between two coordinates ([lng, lat] or [easting, northing] depending on the crs of the map provider). Returns a formatted string (e.g., 'north 400 metres' or 'south 400 metres, west 750 metres').
310
310
  *
311
311
  * @property {() => number} getResolution
312
- * Get map resolution in meters per pixel.
312
+ * Get map resolution in metres per pixel.
313
313
  *
314
314
  * @property {(coords: [number, number]) => { x: number, y: number }} mapToScreen
315
315
  * Convert map coordinates ([lng, lat] or [easting, northing] depending on the crs of the map provider) to screen pixel position (x from left edge, y from top edge of viewport).
@@ -629,6 +629,43 @@
629
629
  * Raw SVG content (without the outer SVG tag).
630
630
  */
631
631
 
632
+ /**
633
+ * Describes a single row in the keyboard shortcuts help panel (opened via Shift+?).
634
+ * Plugins register these via `PluginManifest.keyboardShortcuts`; core (built-in)
635
+ * shortcuts share this same shape internally.
636
+ *
637
+ * @typedef {Object} KeyboardShortcutDefinition
638
+ *
639
+ * @property {string} command
640
+ * HTML string describing the key combination, rendered as markup — wrap keys in
641
+ * `<kbd>` tags, e.g. `'<kbd>Shift</kbd> + <kbd>K</kbd>'`. If it uses the Alt key,
642
+ * label it per platform (macOS calls it Option, not Alt) — see the shared `isMac()`
643
+ * helper and its use in the app's own core shortcuts.
644
+ *
645
+ * @property {'viewport' | 'listbox' | 'global'} [context='viewport']
646
+ * Which help-panel context this shortcut applies to. Used only to choose the panel's
647
+ * default open tab — it doesn't affect whether the row is shown.
648
+ *
649
+ * @property {string} [group='Navigate']
650
+ * Tab label the shortcut is grouped under in the help panel. Shortcuts sharing a group
651
+ * (case/whitespace-insensitive) appear together under one tab; if every visible shortcut
652
+ * shares one group, the panel renders as a flat list with no tabs.
653
+ *
654
+ * @property {string} id
655
+ * Unique shortcut identifier.
656
+ *
657
+ * @property {string[]} [requiredConfig]
658
+ * App config keys that must all be truthy for the shortcut to appear in the help panel.
659
+ *
660
+ * @property {string} title
661
+ * Accessible title shown in the help panel.
662
+ *
663
+ * @property {boolean} [visuallyHidden=false]
664
+ * When true, the row is hidden from sighted users but stays in the DOM and remains
665
+ * discoverable by assistive technology. Use for a shortcut with no visual affordance to
666
+ * discover it by otherwise — e.g. one whose only effect is a screen reader announcement.
667
+ */
668
+
632
669
  /**
633
670
  * Manifest defining a plugin's buttons, panels, controls, API methods, and state.
634
671
  *
@@ -649,6 +686,9 @@
649
686
  * @property {ComponentType} [InitComponent]
650
687
  * Initialization component.
651
688
  *
689
+ * @property {KeyboardShortcutDefinition[]} [keyboardShortcuts]
690
+ * Keyboard shortcut definitions shown in the keyboard shortcuts help panel.
691
+ *
652
692
  * @property {PanelDefinition[]} [panels]
653
693
  * Panel definitions.
654
694
  *
@@ -725,16 +765,18 @@
725
765
  * @property {boolean} [enableFullscreen=false]
726
766
  * Whether a toggle fullscreen button is displayed.
727
767
  *
728
- * @property {boolean} [enableMoveControls=true]
729
- * Whether the move controls are displayed — a button that reveals directional pan,
730
- * zoom, and step-size buttons, providing a non-dragging alternative to panning
731
- * and zooming the map (WCAG 2.5.7). Unlike enableZoomControls, it remains visible
732
- * when the interface type is 'touch'. When enabled, enableZoomControls' buttons
733
- * are hidden to avoid duplicating zoom controls.
768
+ * @property {boolean} [enableMapControls=true]
769
+ * Whether the map controls are displayed — a button that reveals on-screen move,
770
+ * zoom, and precision buttons, plus a target point, typically used to place or
771
+ * select features on the map without a drag gesture. This gives a non-dragging way
772
+ * to operate the map, for anyone who can't perform a drag/pinch gesture (e.g.
773
+ * switch access users) and for voice interfaces such as Voice Control, which can
774
+ * trigger a button click but not a drag. When enabled, enableZoomControls' buttons
775
+ * are hidden to avoid duplication.
734
776
  *
735
777
  * @property {boolean} [enableZoomControls=true]
736
778
  * Whether zoom control buttons are displayed. Not displayed when the interface
737
- * type is 'touch', or when enableMoveControls is enabled.
779
+ * type is 'touch', or when enableMapControls is enabled.
738
780
  *
739
781
  * @property {[number, number, number, number]} [extent]
740
782
  * Initial extent [minX, minY, maxX, maxY]. Equivalent to bounds; use whichever matches your map provider's terminology.
@@ -766,8 +808,8 @@
766
808
  * @property {string} [mapLabel='Interactive map']
767
809
  * Accessible label for the map, announced by screen readers. Also prefixed onto mapHintText, so give each map on a page a distinct label.
768
810
  *
769
- * @property {string} [moveControlsHintText]
770
- * Visually hidden text describing the move controls button, appended to keyboardHintText when enableMoveControls is true.
811
+ * @property {string} [mapControlsHintText]
812
+ * Visually hidden text describing the map controls button, appended to keyboardHintText when enableMapControls is true.
771
813
  *
772
814
  * @property {MapProviderDescriptor} [mapProvider]
773
815
  * A factory function that returns a map provider instance (e.g. maplibreProvider()).
@@ -0,0 +1,29 @@
1
+ import { spatialNavigate } from './spatialNavigate.js'
2
+
3
+ /**
4
+ * Item-list-shaped counterpart to spatialNavigate's raw pixel-array contract: given a list of
5
+ * items each carrying screen coordinates, finds the id of whichever one is spatially nearest
6
+ * to the current item in the given direction. Used to move roving-tabindex focus in a listbox
7
+ * spatially (Alt+Arrow) rather than sequentially (plain Arrow/Home/End) — the same items list
8
+ * already built for sequential navigation works here unchanged, whether it holds map features
9
+ * or (in future) something else entirely, as long as each item carries x/y.
10
+ *
11
+ * Items missing x/y are skipped — they have no screen position to navigate by. If none of the
12
+ * items have coordinates, or the list is empty, currentId is returned unchanged.
13
+ *
14
+ * @param {Array<{id: string, x?: number, y?: number}>} items
15
+ * @param {string|null} currentId
16
+ * @param {'ArrowUp'|'ArrowDown'|'ArrowLeft'|'ArrowRight'} direction
17
+ * @returns {string|null} The id to move to — currentId unchanged if nothing qualifies.
18
+ */
19
+ export function findNearestItemInDirection (items, currentId, direction) {
20
+ const positioned = items.filter(item => item.x != null && item.y != null)
21
+ if (!positioned.length) {
22
+ return currentId
23
+ }
24
+ const pixels = positioned.map(item => [item.x, item.y])
25
+ const currentIndex = positioned.findIndex(item => item.id === currentId)
26
+ const start = currentIndex === -1 ? pixels[0] : pixels[currentIndex]
27
+ const nextIndex = spatialNavigate(start, pixels, direction)
28
+ return positioned[nextIndex]?.id ?? currentId
29
+ }
@@ -0,0 +1,67 @@
1
+ import { findNearestItemInDirection } from './findNearestItemInDirection.js'
2
+
3
+ describe('findNearestItemInDirection', () => {
4
+ const items = [
5
+ { id: 'center', x: 0, y: 0 },
6
+ { id: 'up', x: 0, y: -10 },
7
+ { id: 'down', x: 0, y: 10 },
8
+ { id: 'left', x: -10, y: 0 },
9
+ { id: 'right', x: 10, y: 0 }
10
+ ]
11
+
12
+ test.each([
13
+ ['ArrowUp', 'up'],
14
+ ['ArrowDown', 'down'],
15
+ ['ArrowLeft', 'left'],
16
+ ['ArrowRight', 'right']
17
+ ])('finds the nearest item to the %s of the current one', (direction, expectedId) => {
18
+ expect(findNearestItemInDirection(items, 'center', direction)).toBe(expectedId)
19
+ })
20
+
21
+ test('returns the current id unchanged when nothing qualifies in that direction', () => {
22
+ const single = [{ id: 'only', x: 0, y: 0 }]
23
+ expect(findNearestItemInDirection(single, 'only', 'ArrowUp')).toBe('only')
24
+ })
25
+
26
+ test('returns currentId unchanged when the item list is empty', () => {
27
+ expect(findNearestItemInDirection([], 'anything', 'ArrowUp')).toBe('anything')
28
+ })
29
+
30
+ test('skips items with no screen coordinates', () => {
31
+ const withUnpositioned = [
32
+ { id: 'center', x: 0, y: 0 },
33
+ { id: 'no-coords' },
34
+ { id: 'up', x: 0, y: -10 }
35
+ ]
36
+ expect(findNearestItemInDirection(withUnpositioned, 'center', 'ArrowUp')).toBe('up')
37
+ })
38
+
39
+ test('returns currentId unchanged when no item has coordinates at all', () => {
40
+ const noneWithCoords = [{ id: 'a' }, { id: 'b' }]
41
+ expect(findNearestItemInDirection(noneWithCoords, 'a', 'ArrowUp')).toBe('a')
42
+ })
43
+
44
+ test('treats an unresolvable currentId as starting from the first positioned item', () => {
45
+ expect(findNearestItemInDirection(items, 'not-in-list', 'ArrowRight')).toBe('right')
46
+ })
47
+ })
48
+
49
+ // spatialNavigate's own contract guarantees a valid index in practice (see its own tests), so
50
+ // this can't be reached through findNearestItemInDirection's real dependency — mocked here,
51
+ // matching labels.test.js's identical fallback test for navigateToNextLabel, purely to prove
52
+ // the defensive `?? currentId` itself degrades gracefully rather than crashing if that
53
+ // guarantee were ever weakened.
54
+ describe('findNearestItemInDirection — defensive fallback', () => {
55
+ afterEach(() => {
56
+ jest.dontMock('./spatialNavigate.js')
57
+ jest.resetModules()
58
+ })
59
+
60
+ test('falls back to currentId if spatialNavigate ever returns an out-of-range index', () => {
61
+ jest.resetModules()
62
+ jest.doMock('./spatialNavigate.js', () => ({ spatialNavigate: () => -1 }))
63
+ const { findNearestItemInDirection: fn } = require('./findNearestItemInDirection.js')
64
+ const items = [{ id: 'center', x: 0, y: 0 }, { id: 'up', x: 0, y: -10 }]
65
+ expect(fn(items, 'center', 'ArrowUp')).toBe('center')
66
+ })
67
+ })
@@ -0,0 +1,4 @@
1
+ import { stringToKebab } from './stringToKebab.js'
2
+
3
+ // Shared by MapButton (aria-controls) and Panel (its own root id) so they can't drift apart.
4
+ export const getPanelElementId = (idPrefix, panelId) => `${idPrefix}-panel-${stringToKebab(panelId)}`