@defra/interactive-map 0.0.39-alpha → 0.0.41-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 (387) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/control-definition.md +9 -1
  7. package/docs/api/slots.md +26 -5
  8. package/docs/api.md +4 -4
  9. package/docs/assets/images/slot-map.svg +61 -262
  10. package/docs/examples/draw-tools.mdx +3 -3
  11. package/docs/plugins/draw.md +80 -17
  12. package/docs/plugins/search.md +1 -1
  13. package/govuk-prototype-kit.config.json +2 -0
  14. package/jest.setup.js +5 -0
  15. package/package.json +9 -1
  16. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  17. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  18. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  19. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  20. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  21. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  22. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  23. package/plugins/datasets/dist/css/index.css +1 -1
  24. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  25. package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
  26. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  27. package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
  28. package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
  29. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  30. package/plugins/datasets/dist/umd/index.js +1 -1
  31. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.js +2 -3
  32. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js +175 -0
  33. package/plugins/datasets/src/adapters/esri/registry/esriDataset.js +1 -1
  34. package/plugins/datasets/src/adapters/esri/registry/esriDataset.test.js +55 -0
  35. package/plugins/datasets/src/adapters/loadLayerAdapter.js +1 -1
  36. package/plugins/datasets/src/adapters/loadLayerAdapter.test.js +61 -0
  37. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +1 -3
  38. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +17 -3
  39. package/plugins/datasets/src/api/setOpacity.test.js +9 -0
  40. package/plugins/datasets/src/components/LayersMenu/LayersMenu.jsx +10 -25
  41. package/plugins/datasets/src/components/LayersMenu/LayersMenu.test.jsx +0 -42
  42. package/plugins/datasets/src/datasets.scss +0 -1
  43. package/plugins/datasets/src/fetch/createDynamicSource.js +135 -129
  44. package/plugins/datasets/src/fetch/createDynamicSource.test.js +318 -0
  45. package/plugins/datasets/src/fetch/fetchGeoJSON.test.js +83 -0
  46. package/plugins/datasets/src/index.test.js +36 -0
  47. package/plugins/datasets/src/initialise/DatasetsInit.jsx +17 -7
  48. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +262 -0
  49. package/plugins/datasets/src/initialise/initialiseDatasets.js +16 -5
  50. package/plugins/datasets/src/initialise/initialiseDatasets.test.js +42 -0
  51. package/plugins/datasets/src/manifest.js +0 -38
  52. package/plugins/datasets/src/manifest.test.js +49 -0
  53. package/plugins/datasets/src/reducers/datasetsToMenu.test.js +54 -1
  54. package/plugins/datasets/src/reducers/pluginState.js +3 -16
  55. package/plugins/datasets/src/reducers/pluginState.test.js +23 -0
  56. package/plugins/datasets/src/registry/dataset.js +15 -4
  57. package/plugins/datasets/src/registry/dataset.test.js +61 -0
  58. package/plugins/datasets/src/registry/datasetRegistry.js +12 -9
  59. package/plugins/datasets/src/registry/datasetRegistry.test.js +17 -5
  60. package/plugins/datasets/src/registry/globalDataset.js +0 -3
  61. package/plugins/datasets/src/registry/isVisibleWhen.js +6 -3
  62. package/plugins/datasets/src/registry/isVisibleWhen.test.js +11 -1
  63. package/plugins/datasets/src/utils/bbox.test.js +125 -0
  64. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  65. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  66. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  67. package/plugins/draw/dist/esm/index.js +1 -1
  68. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +2 -1
  69. package/plugins/draw/dist/umd/im-draw-ml-adapter.js.LICENSE.txt +1 -0
  70. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  71. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  72. package/plugins/draw/dist/umd/index.js +1 -1
  73. package/plugins/draw/src/DrawInit.jsx +12 -4
  74. package/plugins/draw/src/DrawInit.test.jsx +51 -10
  75. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +92 -12
  76. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +246 -4
  77. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +109 -14
  78. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +174 -11
  79. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +12 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +9 -1
  81. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +24 -3
  82. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +1 -1
  83. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +1 -1
  84. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +194 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +457 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/__helpers__/harness.js +102 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +82 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.test.js +83 -0
  89. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +55 -0
  90. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.test.js +62 -0
  91. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.js +138 -0
  92. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.test.js +197 -0
  93. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.js +92 -0
  94. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.test.js +101 -0
  95. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.js +46 -0
  96. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.test.js +42 -0
  97. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +151 -0
  98. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +156 -0
  99. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +5 -28
  100. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +24 -39
  101. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +4 -5
  102. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +20 -23
  103. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +25 -5
  104. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +6 -36
  105. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -49
  106. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -54
  107. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +8 -33
  108. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +29 -72
  109. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +11 -4
  110. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.js +158 -0
  111. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.test.js +314 -0
  112. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +6 -1
  113. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +21 -0
  114. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +6 -2
  115. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +12 -0
  116. package/plugins/draw/src/adapters/maplibre/styles.js +38 -11
  117. package/plugins/draw/src/adapters/maplibre/styles.test.js +39 -0
  118. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.js +79 -0
  119. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.test.js +135 -0
  120. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.js +42 -0
  121. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.test.js +85 -0
  122. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.js +34 -0
  123. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.test.js +78 -0
  124. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +16 -3
  125. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +21 -3
  126. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +59 -0
  127. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.test.js +73 -0
  128. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.js +33 -0
  129. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.test.js +70 -0
  130. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +27 -2
  131. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +93 -0
  132. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +9 -0
  133. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +19 -1
  134. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +34 -1
  135. package/plugins/draw/src/adapters/openlayers/core/styles.js +73 -1
  136. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +122 -3
  137. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +1 -0
  138. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +27 -1
  139. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +24 -4
  140. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +47 -3
  141. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +1 -1
  142. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +32 -1
  143. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +9 -5
  144. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +34 -1
  145. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +5 -3
  146. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +11 -0
  147. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +12 -5
  148. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +26 -3
  149. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +18 -13
  150. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +46 -1
  151. package/plugins/draw/src/adapters/openlayers/olDraw.js +11 -4
  152. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +48 -6
  153. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +156 -0
  154. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +246 -0
  155. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +267 -0
  156. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +327 -0
  157. package/plugins/draw/src/adapters/openlayers/point/pointDragInteraction.js +71 -0
  158. package/plugins/draw/src/adapters/openlayers/point/pointOps.js +34 -0
  159. package/plugins/draw/src/adapters/openlayers/point/pointOps.test.js +32 -0
  160. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.js +82 -0
  161. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.test.js +107 -0
  162. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.js +83 -0
  163. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.test.js +214 -0
  164. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +3 -1
  165. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +6 -9
  166. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +15 -4
  167. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +1 -1
  168. package/plugins/draw/src/api/addFeature.js +16 -1
  169. package/plugins/draw/src/api/addFeature.test.js +62 -5
  170. package/plugins/draw/src/api/editFeature.js +7 -4
  171. package/plugins/draw/src/api/editFeature.test.js +16 -0
  172. package/plugins/draw/src/api/newPoint.js +42 -0
  173. package/plugins/draw/src/api/newPoint.test.js +117 -0
  174. package/plugins/draw/src/api/setStyle.js +43 -0
  175. package/plugins/draw/src/api/setStyle.test.js +75 -0
  176. package/plugins/draw/src/events.js +21 -14
  177. package/plugins/draw/src/events.test.js +15 -1
  178. package/plugins/draw/src/manifest.js +18 -6
  179. package/plugins/draw/src/manifest.test.js +32 -1
  180. package/plugins/draw/src/reducer.test.js +7 -0
  181. package/plugins/draw/src/utils/stripInternalProperties.js +19 -0
  182. package/plugins/draw/src/utils/stripInternalProperties.test.js +50 -0
  183. package/plugins/draw/src/utils/symbolKeys.js +5 -0
  184. package/plugins/draw/src/validation/liveDrawChecks.test.js +54 -0
  185. package/plugins/draw/src/validation/liveStroke.test.js +64 -1
  186. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  187. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  188. package/plugins/interact/dist/umd/index.js +1 -1
  189. package/plugins/interact/src/hooks/useCrossHairVisibility.js +12 -4
  190. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +88 -1
  191. package/plugins/interact/src/hooks/useHighlightSync.js +33 -12
  192. package/plugins/interact/src/hooks/useHighlightSync.test.js +96 -25
  193. package/plugins/interact/src/utils/featureQueries.js +18 -0
  194. package/plugins/interact/src/utils/featureQueries.test.js +15 -0
  195. package/plugins/interact/src/utils/spatial.js +9 -3
  196. package/plugins/interact/src/utils/spatial.test.js +12 -1
  197. package/plugins/map-key/dist/css/index.css +62 -0
  198. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -0
  199. package/plugins/map-key/dist/esm/index.js +1 -0
  200. package/plugins/map-key/dist/umd/im-map-key-plugin.js +2 -0
  201. package/plugins/map-key/dist/umd/im-map-key-plugin.js.LICENSE.txt +1 -0
  202. package/plugins/map-key/dist/umd/index.js +2 -0
  203. package/plugins/map-key/dist/umd/index.js.LICENSE.txt +1 -0
  204. package/plugins/map-key/package.json +28 -0
  205. package/plugins/map-key/src/components/Key/EmptyKey.jsx +7 -0
  206. package/plugins/{datasets → map-key}/src/components/Key/EmptyKey.test.jsx +2 -2
  207. package/plugins/map-key/src/components/Key/Key.jsx +41 -0
  208. package/plugins/{datasets → map-key}/src/components/Key/Key.module.scss +15 -14
  209. package/plugins/map-key/src/components/Key/Key.test.jsx +73 -0
  210. package/plugins/map-key/src/components/Key/KeyGroupItem.jsx +16 -0
  211. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +53 -0
  212. package/plugins/map-key/src/components/Key/KeyItem.jsx +29 -0
  213. package/plugins/map-key/src/components/Key/KeyItem.test.jsx +67 -0
  214. package/plugins/map-key/src/components/Key/KeySvg.jsx +42 -0
  215. package/plugins/{datasets → map-key}/src/components/Key/KeySvg.test.jsx +14 -12
  216. package/plugins/map-key/src/components/Key/KeySvgLine.jsx +28 -0
  217. package/plugins/{datasets → map-key}/src/components/Key/KeySvgLine.test.jsx +1 -1
  218. package/plugins/map-key/src/components/Key/KeySvgPattern.jsx +23 -0
  219. package/plugins/{datasets → map-key}/src/components/Key/KeySvgPattern.test.jsx +2 -2
  220. package/plugins/map-key/src/components/Key/KeySvgRect.jsx +33 -0
  221. package/plugins/{datasets → map-key}/src/components/Key/KeySvgRect.test.jsx +1 -1
  222. package/plugins/map-key/src/components/Key/KeySvgSymbol.jsx +27 -0
  223. package/plugins/{datasets → map-key}/src/components/Key/KeySvgSymbol.test.jsx +5 -6
  224. package/plugins/map-key/src/components/Key/MapKey.jsx +50 -0
  225. package/plugins/map-key/src/components/Key/MapKey.test.jsx +130 -0
  226. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.js +2 -2
  227. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.test.js +2 -2
  228. package/plugins/map-key/src/index.js +13 -0
  229. package/plugins/map-key/src/index.test.js +31 -0
  230. package/plugins/map-key/src/initialise/MapKeyInit.jsx +15 -0
  231. package/plugins/map-key/src/initialise/MapKeyInit.test.jsx +39 -0
  232. package/plugins/map-key/src/manifest.js +30 -0
  233. package/plugins/map-key/src/manifest.test.js +63 -0
  234. package/plugins/map-key/src/mapKey.scss +4 -0
  235. package/plugins/map-key/src/registry/getDatasetRegistry.js +6 -0
  236. package/plugins/map-key/src/registry/getDatasetRegistry.test.js +23 -0
  237. package/plugins/map-key/src/registry/index.js +3 -0
  238. package/plugins/map-key/src/registry/index.test.js +15 -0
  239. package/plugins/menu/dist/css/index.css +80 -0
  240. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -0
  241. package/plugins/menu/dist/esm/index.js +1 -0
  242. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -0
  243. package/plugins/menu/dist/umd/index.js +2 -0
  244. package/plugins/menu/dist/umd/index.js.LICENSE.txt +1 -0
  245. package/plugins/menu/package.json +28 -0
  246. package/plugins/menu/src/components/Menu/CheckboxGroupWrapper.jsx +21 -0
  247. package/plugins/menu/src/components/Menu/GroupLegend.jsx +17 -0
  248. package/plugins/menu/src/components/Menu/GroupLegend.test.jsx +68 -0
  249. package/plugins/menu/src/components/Menu/Menu.jsx +19 -0
  250. package/plugins/menu/src/components/Menu/Menu.module.scss +90 -0
  251. package/plugins/menu/src/components/Menu/Menu.test.jsx +161 -0
  252. package/plugins/menu/src/components/Menu/MenuCheckbox.jsx +35 -0
  253. package/plugins/menu/src/components/Menu/MenuCheckbox.test.jsx +93 -0
  254. package/plugins/menu/src/components/Menu/MenuGroup.jsx +11 -0
  255. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.jsx → menu/src/components/Menu/MenuRadio.jsx} +6 -4
  256. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx → menu/src/components/Menu/MenuRadio.test.jsx} +18 -18
  257. package/plugins/menu/src/components/Menu/RadioGroupWrapper.jsx +32 -0
  258. package/plugins/{datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx → menu/src/components/Menu/RadioGroupWrapper.test.jsx} +21 -21
  259. package/plugins/menu/src/components/Menu/useIdPrefix.jsx +4 -0
  260. package/plugins/menu/src/index.js +13 -0
  261. package/plugins/menu/src/index.test.js +31 -0
  262. package/plugins/menu/src/initialise/MenuInit.jsx +24 -0
  263. package/plugins/menu/src/initialise/MenuInit.test.jsx +50 -0
  264. package/plugins/menu/src/initialise/createMenu.js +10 -0
  265. package/plugins/menu/src/initialise/createMenu.test.js +37 -0
  266. package/plugins/menu/src/manifest.js +35 -0
  267. package/plugins/menu/src/manifest.test.js +63 -0
  268. package/plugins/menu/src/menu.scss +4 -0
  269. package/plugins/menu/src/reducers/menuStateReducer.test.js +38 -0
  270. package/plugins/menu/src/reducers/pluginState.js +35 -0
  271. package/plugins/menu/src/reducers/pluginState.test.js +57 -0
  272. package/plugins/menu/src/registry/getDatasetRegistry.js +9 -0
  273. package/plugins/menu/src/registry/getDatasetRegistry.test.js +23 -0
  274. package/plugins/menu/src/registry/index.js +3 -0
  275. package/plugins/menu/src/registry/index.test.js +15 -0
  276. package/plugins/menu/src/registry/isVisibleWhen.js +43 -0
  277. package/plugins/menu/src/registry/isVisibleWhen.test.js +48 -0
  278. package/plugins/search/dist/css/index.css +1 -1
  279. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  280. package/plugins/search/dist/esm/index.js +1 -1
  281. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  282. package/plugins/search/dist/umd/index.js +1 -1
  283. package/plugins/search/src/Search.jsx +7 -5
  284. package/plugins/search/src/components/Form/Form.module.scss +9 -20
  285. package/plugins/search/src/index.js +0 -1
  286. package/plugins/search/src/index.test.js +2 -4
  287. package/plugins/search/src/manifest.js +1 -1
  288. package/plugins/search/src/search.scss +17 -16
  289. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  290. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  291. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  292. package/providers/beta/openlayers/src/openlayersProvider.js +4 -1
  293. package/providers/beta/openlayers/src/openlayersProvider.test.js +18 -0
  294. package/providers/beta/openlayers/src/utils/highlightFeatures.js +52 -5
  295. package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +176 -0
  296. package/providers/beta/openlayers/src/utils/symbolImages.js +40 -0
  297. package/providers/beta/openlayers/src/utils/symbolImages.test.js +58 -0
  298. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  299. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  300. package/providers/maplibre/dist/umd/index.js +1 -1
  301. package/providers/maplibre/src/utils/drawLayerBuckets.js +14 -0
  302. package/providers/maplibre/src/utils/drawLayerBuckets.test.js +18 -0
  303. package/providers/maplibre/src/utils/highlightFeatures.js +80 -55
  304. package/providers/maplibre/src/utils/highlightFeatures.test.js +156 -17
  305. package/providers/maplibre/src/utils/hoverCursor.js +30 -14
  306. package/providers/maplibre/src/utils/hoverCursor.test.js +35 -0
  307. package/providers/maplibre/src/utils/patternImages.js +1 -1
  308. package/providers/maplibre/src/utils/symbolImages.js +41 -3
  309. package/providers/maplibre/src/utils/symbolImages.test.js +40 -6
  310. package/rollup.esm.mjs +11 -1
  311. package/sonar-project.properties +7 -1
  312. package/src/App/components/Actions/Actions.test.jsx +47 -0
  313. package/src/App/components/Attributions/Attributions.module.scss +10 -0
  314. package/src/App/components/Hints/Hints.jsx +7 -2
  315. package/src/App/components/Hints/Hints.test.jsx +13 -0
  316. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +2 -3
  317. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +3 -6
  318. package/src/App/components/{MoveControl/MoveControl.jsx → MoveControls/MoveControls.jsx} +10 -10
  319. package/src/App/components/{MoveControl/MoveControl.module.scss → MoveControls/MoveControls.module.scss} +60 -29
  320. package/src/App/components/{MoveControl/MoveControl.test.jsx → MoveControls/MoveControls.test.jsx} +48 -48
  321. package/src/App/components/Panel/Panel.jsx +18 -3
  322. package/src/App/components/Panel/Panel.module.scss +18 -8
  323. package/src/App/components/Panel/Panel.test.jsx +34 -0
  324. package/src/App/components/Tooltip/Tooltip.test.jsx +12 -14
  325. package/src/App/components/Viewport/Viewport.jsx +9 -1
  326. package/src/App/components/Viewport/Viewport.test.jsx +11 -0
  327. package/src/App/hooks/useFeatureItems.js +16 -7
  328. package/src/App/hooks/useFeatureItems.test.js +57 -0
  329. package/src/App/hooks/useLayoutMeasurements.js +95 -84
  330. package/src/App/hooks/useLayoutMeasurements.test.js +61 -1
  331. package/src/App/initialiseApp.js +11 -8
  332. package/src/App/initialiseApp.test.js +15 -3
  333. package/src/App/layout/Layout.jsx +12 -11
  334. package/src/App/layout/Layout.test.jsx +2 -0
  335. package/src/App/layout/layout.module.scss +31 -17
  336. package/src/App/registry/keyboardShortcutRegistry.js +25 -13
  337. package/src/App/registry/keyboardShortcutRegistry.test.js +49 -32
  338. package/src/App/registry/pluginRegistry.js +2 -3
  339. package/src/App/registry/pluginRegistry.test.js +16 -3
  340. package/src/App/renderer/HtmlElementHost.jsx +18 -5
  341. package/src/App/renderer/HtmlElementHost.test.jsx +54 -0
  342. package/src/App/renderer/mapButtons.js +28 -12
  343. package/src/App/renderer/mapButtons.test.js +17 -4
  344. package/src/App/renderer/mapControls.js +3 -1
  345. package/src/App/renderer/mapControls.test.js +16 -0
  346. package/src/App/renderer/mapPanels.js +44 -9
  347. package/src/App/renderer/mapPanels.test.js +51 -2
  348. package/src/App/renderer/orderItems.js +28 -0
  349. package/src/App/renderer/orderItems.test.js +50 -0
  350. package/src/App/renderer/slotAggregator.js +2 -18
  351. package/src/App/renderer/slotHelpers.js +3 -1
  352. package/src/App/renderer/slotHelpers.test.js +5 -0
  353. package/src/App/renderer/slots.js +3 -0
  354. package/src/App/store/AppProvider.jsx +4 -2
  355. package/src/App/store/appDispatchMiddleware.js +1 -1
  356. package/src/App/store/appDispatchMiddleware.test.js +8 -0
  357. package/src/InteractiveMap/InteractiveMap.js +8 -2
  358. package/src/InteractiveMap/InteractiveMap.test.js +20 -3
  359. package/src/config/appConfig.js +17 -15
  360. package/src/config/appConfig.test.js +22 -22
  361. package/src/config/defaults.js +3 -1
  362. package/src/config/events.js +2 -0
  363. package/src/config/mergeConfig.js +7 -0
  364. package/src/config/mergeConfig.test.js +20 -0
  365. package/src/scss/main.scss +1 -1
  366. package/src/scss/settings/_colors.scss +1 -0
  367. package/src/scss/settings/_dimensions.scss +2 -3
  368. package/src/scss/settings/_transition.scss +1 -1
  369. package/src/services/eventBus.js +44 -0
  370. package/src/services/eventBus.test.js +83 -0
  371. package/src/services/symbolRegistry.js +1 -1
  372. package/src/test-utils.js +5 -0
  373. package/src/types.js +25 -7
  374. package/webpack.umd.mjs +2 -0
  375. package/plugins/datasets/src/components/Key/EmptyKey.jsx +0 -7
  376. package/plugins/datasets/src/components/Key/Key.jsx +0 -50
  377. package/plugins/datasets/src/components/Key/KeyGroupItem.jsx +0 -18
  378. package/plugins/datasets/src/components/Key/KeyItem.jsx +0 -23
  379. package/plugins/datasets/src/components/Key/KeySvg.jsx +0 -22
  380. package/plugins/datasets/src/components/Key/KeySvgLine.jsx +0 -19
  381. package/plugins/datasets/src/components/Key/KeySvgPattern.jsx +0 -13
  382. package/plugins/datasets/src/components/Key/KeySvgRect.jsx +0 -22
  383. package/plugins/datasets/src/components/Key/KeySvgSymbol.jsx +0 -16
  384. package/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx +0 -41
  385. /package/plugins/{datasets → menu}/src/reducers/menuStateReducer.js +0 -0
  386. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.js +0 -0
  387. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.test.js +0 -0
@@ -4,6 +4,10 @@ import { withPluginContexts } from './pluginWrapper.js'
4
4
  import { Panel } from '../components/Panel/Panel.jsx'
5
5
  import { allowedSlots } from './slots.js'
6
6
  import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.js'
7
+ import { mapControls } from './mapControls.js'
8
+ import { orderItems } from './orderItems.js'
9
+ import { stringToKebab } from '../../utils/stringToKebab.js'
10
+ import { logger } from '../../services/logger.js'
7
11
 
8
12
  /**
9
13
  * Determines whether a panel should be rendered in the given slot.
@@ -73,13 +77,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
73
77
  const plugin = pluginRegistry.registeredPlugins.find(p => p.id === config.pluginId)
74
78
  const pluginId = plugin?.id
75
79
 
76
- const WrappedChild = config.render
77
- ? withPluginContexts(config.render, {
78
- ...props,
79
- pluginId,
80
- pluginConfig: plugin?.config
81
- })
82
- : null
80
+ const html = pluginId ? evaluateProp(config.html, pluginId) : config.html
83
81
 
84
82
  return {
85
83
  id: panelId,
@@ -92,12 +90,49 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
92
90
  panelConfig={config}
93
91
  props={props}
94
92
  focusOnOpen={focusOnOpen}
95
- WrappedChild={WrappedChild}
93
+ items={buildPanelBodyItems({ panelId, config, props, plugin, pluginId, html, appState, evaluateProp })}
96
94
  label={evaluateProp(config.label, pluginId)}
97
- html={pluginId ? evaluateProp(config.html, pluginId) : config.html}
95
+ html={html}
98
96
  />
99
97
  )
100
98
  }
101
99
  })
102
100
  .filter(Boolean)
103
101
  }
102
+
103
+ /**
104
+ * Builds the ordered list of body items for a panel: its own render content (if any) plus
105
+ * any controls registered against its `<panelId>-panel` slot by other plugins. Static-html
106
+ * panels don't build an items list — dangerouslySetInnerHTML can't host injected controls,
107
+ * so any controls targeting one are silently skipped (with a dev warning).
108
+ */
109
+ function buildPanelBodyItems ({ panelId, config, props, plugin, pluginId, html, appState, evaluateProp }) {
110
+ const injectedItems = mapControls({
111
+ slot: `${stringToKebab(panelId)}-panel`,
112
+ appState,
113
+ evaluateProp
114
+ })
115
+
116
+ if (html) {
117
+ /* istanbul ignore next */
118
+ if (process.env.NODE_ENV !== 'production' && injectedItems.length > 0) {
119
+ logger.warn(`Panel "${panelId}" uses static html — controls targeting its slot are not rendered.`)
120
+ }
121
+ return undefined
122
+ }
123
+
124
+ if (!config.render) {
125
+ return injectedItems
126
+ }
127
+
128
+ const WrappedChild = withPluginContexts(config.render, {
129
+ ...props,
130
+ pluginId,
131
+ pluginConfig: plugin?.config
132
+ })
133
+
134
+ return orderItems([
135
+ { id: panelId, order: 0, element: <WrappedChild {...props} /> },
136
+ ...injectedItems
137
+ ])
138
+ }
@@ -2,10 +2,14 @@ import React from 'react'
2
2
  import { mapPanels } from './mapPanels.js'
3
3
  import { registeredPlugins } from '../registry/pluginRegistry.js'
4
4
  import { withPluginContexts } from './pluginWrapper.js'
5
+ import { mapControls } from './mapControls.js'
6
+ import { logger } from '../../services/logger.js'
5
7
 
8
+ jest.mock('../../services/logger.js')
6
9
  jest.mock('../registry/panelRegistry.js')
7
10
  jest.mock('../registry/pluginRegistry.js', () => ({ registeredPlugins: [] }))
8
11
  jest.mock('./pluginWrapper.js', () => ({ withPluginContexts: jest.fn((c) => c) }))
12
+ jest.mock('./mapControls.js', () => ({ mapControls: jest.fn(() => []) }))
9
13
  jest.mock('../components/Panel/Panel.jsx', () => ({ Panel: (props) => <div data-testid='panel' {...props} /> }))
10
14
  jest.mock('./slots.js', () => ({ allowedSlots: { panel: ['header', 'modal', 'left-top'] } }))
11
15
 
@@ -23,6 +27,7 @@ describe('mapPanels', () => {
23
27
 
24
28
  beforeEach(() => {
25
29
  jest.clearAllMocks()
30
+ mapControls.mockReturnValue([])
26
31
  registeredPlugins.length = 0
27
32
  defaultAppState = {
28
33
  breakpoint: 'desktop',
@@ -116,9 +121,53 @@ describe('mapPanels', () => {
116
121
  )
117
122
  })
118
123
 
119
- it('sets WrappedChild null if no render function', () => {
124
+ it('builds an empty items list if there is no render function and no injected controls', () => {
120
125
  const result = map()
121
- expect(result[0].element.props.WrappedChild).toBeNull()
126
+ expect(result[0].element.props.items).toEqual([])
127
+ })
128
+
129
+ it('builds a single-item items list for a panel with its own render content', () => {
130
+ const renderFn = () => <div>child</div>
131
+ defaultAppState.panelConfig = ({ p1: { ...baseConfig, render: renderFn } })
132
+ const result = map()
133
+ expect(result[0].element.props.items).toEqual([
134
+ { id: 'p1', order: 0, element: expect.anything() }
135
+ ])
136
+ })
137
+
138
+ it('requests injected controls using the <panelId>-panel slot convention', () => {
139
+ map()
140
+ expect(mapControls).toHaveBeenCalledWith(expect.objectContaining({ slot: 'p1-panel' }))
141
+ })
142
+
143
+ it('merges and orders the panel\'s own content with controls injected via mapControls', () => {
144
+ const renderFn = () => <div>child</div>
145
+ mapControls.mockReturnValue([{ id: 'injected1', order: 1, element: <span>injected</span> }])
146
+ defaultAppState.panelConfig = ({ p1: { ...baseConfig, render: renderFn } })
147
+ const result = map()
148
+ // injected1 has order 1, so it's spliced ahead of the panel's own (unordered) content
149
+ expect(result[0].element.props.items.map(i => i.id)).toEqual(['injected1', 'p1'])
150
+ })
151
+
152
+ it('returns just the injected controls when the panel has neither render nor html', () => {
153
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, element: <span>injected</span> }])
154
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'] } })
155
+ const result = map()
156
+ expect(result[0].element.props.items.map(i => i.id)).toEqual(['injected1'])
157
+ })
158
+
159
+ it('does not build an items list for a static-html panel (dangerouslySetInnerHTML can\'t host injected controls)', () => {
160
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'], pluginId: 'plug1', html: '<p>Hi</p>' } })
161
+ const result = map()
162
+ expect(result[0].element.props.items).toBeUndefined()
163
+ expect(result[0].element.props.html).toBe('<p>Hi</p>')
164
+ })
165
+
166
+ it('warns in dev when controls target a static-html panel', () => {
167
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, element: <span>injected</span> }])
168
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'], pluginId: 'plug1', html: '<p>Hi</p>' } })
169
+ map()
170
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('p1'))
122
171
  })
123
172
 
124
173
  it('returns correct structure and defaults', () => {
@@ -0,0 +1,28 @@
1
+ // src/core/renderers/orderItems.js
2
+
3
+ /**
4
+ * Orders a list of `{ id, order, element }` items for a single render target
5
+ * (a layout slot, or a panel body).
6
+ *
7
+ * Items without an explicit `order` (or `order === 0`) keep their natural/registration
8
+ * sequence. Items with an explicit `order` are spliced into that 1-based position among
9
+ * the unordered items, clamped to the valid range.
10
+ */
11
+ export function orderItems (items) {
12
+ // Start with items that have no explicit order (in their natural sequence)
13
+ const withoutOrder = items.filter(item => item.order == null || item.order === 0)
14
+ const withOrder = items.filter(item => item.order != null && item.order !== 0)
15
+
16
+ // Sort items with order by their order value (stable sort preserves ties)
17
+ withOrder.sort((a, b) => a.order - b.order)
18
+
19
+ // Insert each ordered item at its specified position
20
+ const result = [...withoutOrder]
21
+ for (const item of withOrder) {
22
+ // Clamp position to valid range (1-based, so subtract 1 for array index)
23
+ const pos = Math.min(Math.max(item.order - 1, 0), result.length)
24
+ result.splice(pos, 0, item)
25
+ }
26
+
27
+ return result
28
+ }
@@ -0,0 +1,50 @@
1
+ import { orderItems } from './orderItems.js'
2
+
3
+ describe('orderItems', () => {
4
+ it('keeps unordered items in their natural sequence', () => {
5
+ const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]
6
+ expect(orderItems(items).map(i => i.id)).toEqual(['a', 'b', 'c'])
7
+ })
8
+
9
+ it('treats order 0 the same as no order', () => {
10
+ const items = [{ id: 'a', order: 0 }, { id: 'b' }]
11
+ expect(orderItems(items).map(i => i.id)).toEqual(['a', 'b'])
12
+ })
13
+
14
+ it('inserts an ordered item at its 1-based position among unordered items', () => {
15
+ const items = [{ id: 'a' }, { id: 'b' }, { id: 'c', order: 1 }]
16
+ expect(orderItems(items).map(i => i.id)).toEqual(['c', 'a', 'b'])
17
+ })
18
+
19
+ it('splices ordered items in ascending order, each relative to the growing result', () => {
20
+ const items = [
21
+ { id: 'a' },
22
+ { id: 'b', order: 2 },
23
+ { id: 'c', order: 1 }
24
+ ]
25
+ // c (order 1) is spliced in first, ahead of 'a': [c, a]
26
+ // b (order 2) is then spliced at index 1 of that 2-item result: [c, b, a]
27
+ expect(orderItems(items).map(i => i.id)).toEqual(['c', 'b', 'a'])
28
+ })
29
+
30
+ it('clamps an order larger than the list to the end', () => {
31
+ const items = [{ id: 'a' }, { id: 'b' }, { id: 'c', order: 99 }]
32
+ expect(orderItems(items).map(i => i.id)).toEqual(['a', 'b', 'c'])
33
+ })
34
+
35
+ it('splices equal-order items each at position 0, reversing their relative order', () => {
36
+ // The stable sort keeps a before b, but with no unordered items to anchor against,
37
+ // each splice(0, 0, item) inserts ahead of the previous one.
38
+ const items = [{ id: 'a', order: 1 }, { id: 'b', order: 1 }]
39
+ expect(orderItems(items).map(i => i.id)).toEqual(['b', 'a'])
40
+ })
41
+
42
+ it('handles an all-ordered list', () => {
43
+ const items = [{ id: 'a', order: 2 }, { id: 'b', order: 1 }]
44
+ expect(orderItems(items).map(i => i.id)).toEqual(['b', 'a'])
45
+ })
46
+
47
+ it('handles an empty list', () => {
48
+ expect(orderItems([])).toEqual([])
49
+ })
50
+ })
@@ -2,28 +2,12 @@
2
2
  import { mapControls } from './mapControls.js'
3
3
  import { mapPanels } from './mapPanels.js'
4
4
  import { mapButtons } from './mapButtons.js'
5
+ import { orderItems } from './orderItems.js'
5
6
 
6
7
  export function getSlotItems ({ evaluateProp, ...args }) {
7
8
  const controls = mapControls({ evaluateProp, ...args })
8
9
  const panels = mapPanels({ evaluateProp, ...args })
9
10
  const buttons = mapButtons({ evaluateProp, ...args })
10
11
 
11
- const items = [...controls, ...panels, ...buttons]
12
-
13
- // Start with items that have no explicit order (in their natural sequence)
14
- const withoutOrder = items.filter(item => item.order == null || item.order === 0)
15
- const withOrder = items.filter(item => item.order != null && item.order !== 0)
16
-
17
- // Sort items with order by their order value (stable sort preserves ties)
18
- withOrder.sort((a, b) => a.order - b.order)
19
-
20
- // Insert each ordered item at its specified position
21
- const result = [...withoutOrder]
22
- for (const item of withOrder) {
23
- // Clamp position to valid range (1-based, so subtract 1 for array index)
24
- const pos = Math.min(Math.max(item.order - 1, 0), result.length)
25
- result.splice(pos, 0, item)
26
- }
27
-
28
- return result
12
+ return orderItems([...controls, ...panels, ...buttons])
29
13
  }
@@ -39,7 +39,9 @@ export const isControlVisible = (control, { breakpoint, mode, isFullscreen }) =>
39
39
  if (!bpConfig) {
40
40
  return false
41
41
  }
42
- if (!allowedSlots.control.includes(bpConfig.slot)) {
42
+ // A control may also target a panel's body directly via the `<panelId>-panel`
43
+ // slot convention (mirrors the `<buttonId>-button` convention panels already use).
44
+ if (!allowedSlots.control.includes(bpConfig.slot) && !bpConfig.slot?.endsWith('-panel')) {
43
45
  return false
44
46
  }
45
47
  if (!isModeAllowed(control, mode)) {
@@ -65,6 +65,11 @@ describe('isControlVisible', () => {
65
65
  it('returns true when inline:false and fullscreen', () => {
66
66
  expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', mode: 'view', isFullscreen: true })).toBe(true)
67
67
  })
68
+
69
+ it('returns true for a control targeting a panel-body slot via the <panelId>-panel convention', () => {
70
+ const panelTargeting = { desktop: { slot: 'map-styles-panel' } }
71
+ expect(isControlVisible(panelTargeting, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(true)
72
+ })
68
73
  })
69
74
 
70
75
  describe('isConsumerHtml', () => {
@@ -1,6 +1,7 @@
1
1
  // src/core/renderers/slots.js
2
2
  export const layoutSlots = Object.freeze({
3
3
  SIDE: 'side',
4
+ HEADER: 'header',
4
5
  BANNER: 'banner',
5
6
  TOP_LEFT: 'top-left',
6
7
  TOP_MIDDLE: 'top-middle',
@@ -19,6 +20,7 @@ export const layoutSlots = Object.freeze({
19
20
 
20
21
  export const allowedSlots = Object.freeze({
21
22
  control: [
23
+ layoutSlots.HEADER,
22
24
  layoutSlots.BANNER,
23
25
  layoutSlots.TOP_LEFT,
24
26
  layoutSlots.TOP_RIGHT,
@@ -31,6 +33,7 @@ export const allowedSlots = Object.freeze({
31
33
  ],
32
34
  panel: [
33
35
  layoutSlots.SIDE,
36
+ layoutSlots.HEADER,
34
37
  layoutSlots.BANNER,
35
38
  layoutSlots.LEFT_TOP,
36
39
  layoutSlots.LEFT_BOTTOM,
@@ -10,12 +10,13 @@ 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, eventBus, breakpointDetector } = options
13
+ const { pluginRegistry, buttonRegistry, panelRegistry, controlRegistry, keyboardShortcutRegistry, eventBus, breakpointDetector } = options
14
14
 
15
15
  const layoutRefs = {
16
16
  appContainerRef: useRef(null),
17
17
  sideRef: useRef(null),
18
18
  mainRef: useRef(null),
19
+ headerRef: useRef(null),
19
20
  topRef: useRef(null),
20
21
  topLeftColRef: useRef(null),
21
22
  topRightColRef: useRef(null),
@@ -94,7 +95,8 @@ export const AppProvider = ({ options, children }) => {
94
95
  pluginRegistry,
95
96
  buttonRegistry,
96
97
  panelRegistry,
97
- controlRegistry
98
+ controlRegistry,
99
+ keyboardShortcutRegistry
98
100
  }), [state, dispatch])
99
101
 
100
102
  return (
@@ -101,7 +101,7 @@ export function handleActionSideEffects (action, previousState, panelConfig, eve
101
101
  const mergedConfig = deepMerge(defaultControlConfig, config)
102
102
  BREAKPOINTS.forEach(bp => {
103
103
  const slot = mergedConfig[bp]?.slot
104
- if (slot && !allowedSlots.control.includes(slot)) {
104
+ if (slot && !allowedSlots.control.includes(slot) && !slot.endsWith('-panel')) {
105
105
  logger.warn(`control "${id}" has invalid slot "${slot}" at breakpoint "${bp}". Allowed slots: ${allowedSlots.control.join(', ')}.`)
106
106
  }
107
107
  })
@@ -185,6 +185,14 @@ describe('ADD_CONTROL', () => {
185
185
  )
186
186
  expect(console.warn).not.toHaveBeenCalled()
187
187
  })
188
+
189
+ it('does not warn for a control targeting a panel-body slot', () => {
190
+ run(
191
+ { type: 'ADD_CONTROL', payload: { id: 'myCtrl', config: { desktop: { slot: 'map-styles-panel' } } } },
192
+ { breakpoint: 'desktop' }
193
+ )
194
+ expect(console.warn).not.toHaveBeenCalled()
195
+ })
188
196
  })
189
197
 
190
198
  describe('ADD_PANEL', () => {
@@ -111,13 +111,16 @@ export default class InteractiveMap {
111
111
  history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
112
112
  }
113
113
  this.showApp()
114
+ // App is already mounted, so focus the viewport directly rather than via a mount effect.
115
+ this.rootEl.querySelector('[role="application"]')?.focus()
114
116
  } else if (this._root) {
115
117
  // app already open — no-op, no push
116
118
  } else {
117
119
  if (this.config.manageHistoryState) {
118
120
  history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
119
121
  }
120
- this.loadApp()
122
+ // Only a genuine launcher click moves focus into the map — never a resize/breakpoint transition.
123
+ this.loadApp({ focusOnMount: true })
121
124
  }
122
125
  }
123
126
 
@@ -175,9 +178,11 @@ export default class InteractiveMap {
175
178
  * Load and initialize the map application.
176
179
  *
177
180
  * @internal Not intended for end-user use.
181
+ * @param {Object} [options]
182
+ * @param {boolean} [options.focusOnMount=false] - Focus the viewport once mounted; only set by a genuine launcher click.
178
183
  * @returns {Promise<void>}
179
184
  */
180
- async loadApp () {
185
+ async loadApp ({ focusOnMount = false } = {}) {
181
186
  if (this._root || this._isLoading) {
182
187
  return
183
188
  }
@@ -204,6 +209,7 @@ export default class InteractiveMap {
204
209
  id: this.id,
205
210
  initialBreakpoint: this._breakpointDetector.getBreakpoint(),
206
211
  initialInterfaceType: getInterfaceType(),
212
+ focusOnMount,
207
213
  ...this.config,
208
214
  MapProvider,
209
215
  mapProviderConfig,
@@ -185,6 +185,18 @@ describe('InteractiveMap — loadApp', () => {
185
185
  expect(typeof map.someMethod).toBe('function')
186
186
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: false, isFullscreen: false })
187
187
  })
188
+
189
+ it('defaults focusOnMount to false when not passed', async () => {
190
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
191
+ await map.loadApp()
192
+ expect(initialiseApp).toHaveBeenCalledWith(rootEl, expect.objectContaining({ focusOnMount: false }))
193
+ })
194
+
195
+ it('passes focusOnMount through to initialiseApp when set', async () => {
196
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
197
+ await map.loadApp({ focusOnMount: true })
198
+ expect(initialiseApp).toHaveBeenCalledWith(rootEl, expect.objectContaining({ focusOnMount: true }))
199
+ })
188
200
  })
189
201
 
190
202
  // --- _handleButtonClick ---
@@ -193,25 +205,30 @@ describe('InteractiveMap — _handleButtonClick', () => {
193
205
  beforeEach(setupBeforeEach)
194
206
  afterEach(() => jest.restoreAllMocks())
195
207
 
196
- it('pushState and loadApp on first open', async () => {
208
+ it('pushState and loadApp (with focusOnMount) on first open', async () => {
197
209
  const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: true, mapProvider: mapProviderMock })
198
210
  jest.spyOn(map, 'loadApp').mockResolvedValue()
199
211
  jest.spyOn(history, 'pushState').mockImplementation(() => {})
200
212
  await openButtonCallback({ currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } })
201
- expect(map.loadApp).toHaveBeenCalled()
213
+ expect(map.loadApp).toHaveBeenCalledWith({ focusOnMount: true })
202
214
  expect(history.pushState).toHaveBeenCalledWith({ isBack: true }, '', '/?mv=map')
203
215
  })
204
216
 
205
- it('calls showApp (not loadApp) when map is hidden', async () => {
217
+ it('calls showApp (not loadApp) and focuses the viewport when map is hidden', async () => {
206
218
  const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: true, mapProvider: mapProviderMock })
207
219
  map._isHidden = true
208
220
  jest.spyOn(map, 'showApp').mockImplementation(() => {})
209
221
  jest.spyOn(map, 'loadApp').mockResolvedValue()
210
222
  jest.spyOn(history, 'pushState').mockImplementation(() => {})
223
+ const viewport = document.createElement('div')
224
+ viewport.setAttribute('role', 'application')
225
+ viewport.setAttribute('tabindex', '0')
226
+ rootEl.appendChild(viewport)
211
227
  await openButtonCallback({ currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } })
212
228
  expect(map.showApp).toHaveBeenCalled()
213
229
  expect(map.loadApp).not.toHaveBeenCalled()
214
230
  expect(history.pushState).toHaveBeenCalled()
231
+ expect(viewport).toHaveFocus()
215
232
  })
216
233
 
217
234
  it('skips pushState when manageHistoryState is false', async () => {
@@ -1,5 +1,5 @@
1
1
  import { KeyboardHelp } from '../App/components/KeyboardHelp/KeyboardHelp.jsx'
2
- import { MoveControl } from '../App/components/MoveControl/MoveControl.jsx'
2
+ import { MoveControls } from '../App/components/MoveControls/MoveControls.jsx'
3
3
 
4
4
  const keyboardBasePanelSlots = {
5
5
  slot: 'middle',
@@ -13,13 +13,15 @@ const buttonSlots = {
13
13
  showLabel: false
14
14
  }
15
15
 
16
- const moveControlSlot = {
16
+ const moveControlsSlot = {
17
17
  slot: 'right-bottom'
18
18
  }
19
19
 
20
20
  const exitButtonSlots = {
21
21
  slot: 'top-left',
22
- showLabel: false
22
+ showLabel: false,
23
+ // Explicit order so exit always sorts first in its slot.
24
+ order: 1
23
25
  }
24
26
 
25
27
  const journeyBackSlots = { slot: 'actions', showLabel: true }
@@ -77,7 +79,7 @@ export const defaultAppConfig = {
77
79
  iconId: 'plus',
78
80
  keepFocus: true,
79
81
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta),
80
- excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControl || appState.interfaceType === 'touch',
82
+ excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControls || appState.interfaceType === 'touch',
81
83
  enableWhen: ({ mapState }) => !mapState.isAtMaxZoom,
82
84
  mobile: buttonSlots,
83
85
  tablet: buttonSlots,
@@ -89,23 +91,23 @@ export const defaultAppConfig = {
89
91
  iconId: 'minus',
90
92
  keepFocus: true,
91
93
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta),
92
- excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControl || appState.interfaceType === 'touch',
94
+ excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControls || appState.interfaceType === 'touch',
93
95
  enableWhen: ({ mapState }) => !mapState.isAtMinZoom,
94
96
  mobile: buttonSlots,
95
97
  tablet: buttonSlots,
96
98
  desktop: buttonSlots
97
99
  }, {
98
- id: 'moveControl',
100
+ id: 'moveControls',
99
101
  label: 'Move and zoom controls',
100
102
  iconId: 'move',
101
103
  keepFocus: true,
102
104
  isExpanded: false,
103
- ariaControls: ({ appConfig }) => `${appConfig.id}-move-control-content`,
105
+ ariaControls: ({ appConfig }) => `${appConfig.id}-move-controls-content`,
104
106
  onClick: (_e, { appState }) => appState.dispatch({
105
107
  type: 'TOGGLE_BUTTON_EXPANDED',
106
- payload: { id: 'moveControl', isExpanded: !appState.expandedButtons.has('moveControl') }
108
+ payload: { id: 'moveControls', isExpanded: !appState.expandedButtons.has('moveControls') }
107
109
  }),
108
- excludeWhen: ({ appConfig }) => !appConfig.enableMoveControl,
110
+ excludeWhen: ({ appConfig }) => !appConfig.enableMoveControls,
109
111
  mobile: buttonSlots,
110
112
  tablet: buttonSlots,
111
113
  desktop: buttonSlots
@@ -129,13 +131,13 @@ export const defaultAppConfig = {
129
131
  }],
130
132
 
131
133
  controls: [{
132
- id: 'moveControl',
134
+ id: 'moveControls',
133
135
  label: 'Move and zoom',
134
- excludeWhen: ({ appConfig }) => !appConfig.enableMoveControl,
135
- mobile: moveControlSlot,
136
- tablet: moveControlSlot,
137
- desktop: moveControlSlot,
138
- render: MoveControl
136
+ excludeWhen: ({ appConfig }) => !appConfig.enableMoveControls,
137
+ mobile: moveControlsSlot,
138
+ tablet: moveControlsSlot,
139
+ desktop: moveControlsSlot,
140
+ render: MoveControls
139
141
  }],
140
142
 
141
143
  icons: [{
@@ -2,7 +2,7 @@ import { render } from '@testing-library/react'
2
2
  import { defaultAppConfig, defaultButtonConfig, scaleFactor } from './appConfig'
3
3
 
4
4
  jest.mock('../App/store/appContext.js', () => ({
5
- useApp: () => ({ listboxIsActive: false })
5
+ useApp: () => ({ listboxIsActive: false, keyboardShortcutRegistry: { getKeyboardShortcuts: () => [] } })
6
6
  }))
7
7
 
8
8
  describe('defaultAppConfig', () => {
@@ -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 moveControlBtn = buttons.find(b => b.id === 'moveControl')
22
+ const moveControlsBtn = buttons.find(b => b.id === 'moveControls')
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 (enableMoveControl)
147
+ // Branch B: Second part of OR is true (enableMoveControls)
148
148
  expect(btn.excludeWhen({
149
- appConfig: { enableZoomControls: true, enableMoveControl: true },
149
+ appConfig: { enableZoomControls: true, enableMoveControls: 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, enableMoveControl: false },
155
+ appConfig: { enableZoomControls: true, enableMoveControls: 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, enableMoveControl: false },
161
+ appConfig: { enableZoomControls: true, enableMoveControls: false },
162
162
  appState: { interfaceType: 'mouse' }
163
163
  })).toBe(false)
164
164
  })
@@ -182,38 +182,38 @@ describe('defaultAppConfig', () => {
182
182
  expect(mapProviderMock.zoomOut).toHaveBeenCalledWith(2)
183
183
  })
184
184
 
185
- // --- MOVE CONTROL TOGGLE BUTTON ---
186
- it('covers all branches of moveControl excludeWhen', () => {
187
- expect(moveControlBtn.excludeWhen({ appConfig: { enableMoveControl: false } })).toBe(true)
188
- expect(moveControlBtn.excludeWhen({ appConfig: { enableMoveControl: true } })).toBe(false)
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)
189
189
  })
190
190
 
191
- it('moveControl ariaControls resolves the control element id', () => {
192
- expect(moveControlBtn.ariaControls({ appConfig: { id: 'im' } })).toBe('im-move-control-content')
191
+ it('moveControls ariaControls resolves the control element id', () => {
192
+ expect(moveControlsBtn.ariaControls({ appConfig: { id: 'im' } })).toBe('im-move-controls-content')
193
193
  })
194
194
 
195
- it('moveControl onClick toggles TOGGLE_BUTTON_EXPANDED based on current state', () => {
195
+ it('moveControls onClick toggles TOGGLE_BUTTON_EXPANDED based on current state', () => {
196
196
  const dispatch = jest.fn()
197
197
 
198
- moveControlBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set() } })
198
+ moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set() } })
199
199
  expect(dispatch).toHaveBeenCalledWith({
200
200
  type: 'TOGGLE_BUTTON_EXPANDED',
201
- payload: { id: 'moveControl', isExpanded: true }
201
+ payload: { id: 'moveControls', isExpanded: true }
202
202
  })
203
203
 
204
204
  dispatch.mockClear()
205
- moveControlBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set(['moveControl']) } })
205
+ moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set(['moveControls']) } })
206
206
  expect(dispatch).toHaveBeenCalledWith({
207
207
  type: 'TOGGLE_BUTTON_EXPANDED',
208
- payload: { id: 'moveControl', isExpanded: false }
208
+ payload: { id: 'moveControls', isExpanded: false }
209
209
  })
210
210
  })
211
211
 
212
- // --- MOVE CONTROL ---
213
- it('covers all branches of moveControl control excludeWhen', () => {
214
- const control = defaultAppConfig.controls.find(c => c.id === 'moveControl')
215
- expect(control.excludeWhen({ appConfig: { enableMoveControl: false } })).toBe(true)
216
- expect(control.excludeWhen({ appConfig: { enableMoveControl: true } })).toBe(false)
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)
217
217
  })
218
218
 
219
219
  // --- SUPPLEMENTARY CONFIGS ---