@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
@@ -2,6 +2,7 @@ import { renderHook, act } from '@testing-library/react'
2
2
  import { useFeatureItems } from './useFeatureItems.js'
3
3
 
4
4
  const SET_FEATURES = 'map:setfeatures' // NOSONAR
5
+ const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed' // NOSONAR
5
6
 
6
7
  const makeEventBus = () => {
7
8
  const listeners = {}
@@ -37,11 +38,18 @@ describe('useFeatureItems — event subscription', () => {
37
38
  expect(eb.on).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
38
39
  })
39
40
 
41
+ it('subscribes to map:setfeaturessuppressed on mount', () => {
42
+ const eb = makeEventBus()
43
+ renderHook(() => useFeatureItems(eb))
44
+ expect(eb.on).toHaveBeenCalledWith(SET_FEATURES_SUPPRESSED, expect.any(Function))
45
+ })
46
+
40
47
  it('unsubscribes on unmount', () => {
41
48
  const eb = makeEventBus()
42
49
  const { unmount } = renderHook(() => useFeatureItems(eb))
43
50
  unmount()
44
51
  expect(eb.off).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
52
+ expect(eb.off).toHaveBeenCalledWith(SET_FEATURES_SUPPRESSED, expect.any(Function))
45
53
  })
46
54
 
47
55
  it('does not subscribe when eventBus is undefined', () => {
@@ -96,3 +104,52 @@ describe('useFeatureItems — multiselectable updates', () => {
96
104
  expect(result.current.multiselectable).toBe(false)
97
105
  })
98
106
  })
107
+
108
+ // ─── useFeatureItems — suppression ───────────────────────────────────────────
109
+
110
+ describe('useFeatureItems — suppression', () => {
111
+ const items = [{ id: 'a', label: 'Feature A' }]
112
+
113
+ it('reports empty items while suppressed, regardless of what was last set', () => {
114
+ const eb = makeEventBus()
115
+ const { result } = renderHook(() => useFeatureItems(eb))
116
+ act(() => eb.emit(SET_FEATURES, { items }))
117
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
118
+ expect(result.current.items).toEqual([])
119
+ })
120
+
121
+ it('restores the last known items once suppression is lifted', () => {
122
+ const eb = makeEventBus()
123
+ const { result } = renderHook(() => useFeatureItems(eb))
124
+ act(() => eb.emit(SET_FEATURES, { items }))
125
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
126
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
127
+ expect(result.current.items).toEqual(items)
128
+ })
129
+
130
+ it('defaults suppressed to false when the payload omits it', () => {
131
+ const eb = makeEventBus()
132
+ const { result } = renderHook(() => useFeatureItems(eb))
133
+ act(() => eb.emit(SET_FEATURES, { items }))
134
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, {}))
135
+ expect(result.current.items).toEqual(items)
136
+ })
137
+
138
+ it('defaults suppressed to false when the event carries no payload at all', () => {
139
+ const eb = makeEventBus()
140
+ const { result } = renderHook(() => useFeatureItems(eb))
141
+ act(() => eb.emit(SET_FEATURES, { items }))
142
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED))
143
+ expect(result.current.items).toEqual(items)
144
+ })
145
+
146
+ it('a features update received while suppressed is still applied once restored', () => {
147
+ const eb = makeEventBus()
148
+ const { result } = renderHook(() => useFeatureItems(eb))
149
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
150
+ act(() => eb.emit(SET_FEATURES, { items }))
151
+ expect(result.current.items).toEqual([]) // still suppressed
152
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
153
+ expect(result.current.items).toEqual(items)
154
+ })
155
+ })
@@ -4,58 +4,61 @@ import { useApp } from '../store/appContext.js'
4
4
  import { useMap } from '../store/mapContext.js'
5
5
  import { getSafeZoneInset } from '../../utils/getSafeZoneInset.js'
6
6
 
7
+ const BANNER_DOCKED_CLASS = 'im-o-app__banner--docked'
8
+ const BANNER_PANEL_SELECTOR = '.im-c-panel--banner'
9
+
7
10
  const buttonHeight = (ref) => ref?.current?.offsetHeight ?? 0
11
+ const buttonWidth = (ref) => ref?.current?.offsetWidth ?? 0
12
+
13
+ // Max of both sides, so centred content doesn't lean toward the emptier one.
14
+ const symmetricWidth = (left, right) => left || right ? Math.max(left, right) : 0
15
+
16
+ const subSlotMaxHeight = (columnHeight, siblingButtons, gap) => columnHeight - (siblingButtons ? siblingButtons + gap : 0)
17
+
18
+ // bottomRightHeight is 0 when empty, falling back to the attributions' own height for spacing.
19
+ const rightOffsetBottom = (containerPad, bottomRightHeight, attributionsHeight, gap) =>
20
+ containerPad + (bottomRightHeight > 0 ? bottomRightHeight + gap : attributionsHeight)
21
+
22
+ // Mobile's actions bar sits in flow (already reflected in baseBottom); tablet/desktop's floats, so this clears it too.
23
+ const hintBottom = (main, bottom, actionsEl, gap) => {
24
+ const baseBottom = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
25
+ const actionsHeight = actionsEl?.offsetHeight ?? 0
26
+ const actionsOffset = actionsHeight > 0 ? main.offsetHeight - actionsEl.offsetTop : 0
27
+ return Math.max(baseBottom, actionsOffset + gap)
28
+ }
8
29
 
9
- const topColWidth = (left, right) =>
10
- left || right ? Math.max(left, right) : 0
30
+ // Space between .im-o-app__left/.im-o-app__right for the banner to dock in.
31
+ const bannerGutterWidth = (mainWidth, sideColWidth, gap) => mainWidth - (sideColWidth * 2) - (gap * 2)
11
32
 
12
- const subSlotMaxHeight = (columnHeight, siblingButtons, gap) =>
13
- columnHeight - (siblingButtons ? siblingButtons + gap : 0)
33
+ const isBannerDocked = (gutterWidth, preferredWidth) => gutterWidth >= preferredWidth
34
+
35
+ // Widest explicit width configured on a banner panel, if any, overriding the default.
36
+ const bannerConfiguredWidth = (bannerEl) => {
37
+ const widths = Array.from(bannerEl?.querySelectorAll(BANNER_PANEL_SELECTOR) ?? [])
38
+ .map(el => Number.parseInt(el.style.width, 10))
39
+ .filter(w => !Number.isNaN(w))
40
+ return widths.length ? Math.max(...widths) : null
41
+ }
42
+
43
+ // Mobile always stacks full-width, so any inline width Panel.jsx applied is ignored.
44
+ const clearBannerPanelWidths = (bannerEl) => {
45
+ bannerEl?.querySelectorAll(BANNER_PANEL_SELECTOR).forEach(el => { el.style.width = '' })
46
+ }
47
+
48
+ // Docked insets to the side-column width; stacked is full-bleed.
49
+ const bannerInset = (isDocked, primaryGap, sideColWidth, gap) =>
50
+ isDocked ? primaryGap + sideColWidth + gap : primaryGap
14
51
 
15
52
  /**
16
- * Manages all layout measurements for the map overlay and dispatches the safe
17
- * zone inset used by the map to pad `fitBounds` / `setView` operations.
18
- *
19
- * ## Lifecycle
20
- *
21
- * The safe zone must only be dispatched once every plugin button's reactive
22
- * props (`hiddenWhen`, `enableWhen`, `pressedWhen`, `expandedWhen`) have been
23
- * evaluated for the current app/map state. Dispatching too early — before
24
- * buttons that affect layout (e.g. the actions bar) have their correct
25
- * visibility — produces a stale inset that causes the map to jump when the UI
26
- * then settles into its real state.
27
- *
28
- * ### Trigger events
29
- * The following state changes can alter which buttons are visible and therefore
30
- * how much space the UI occupies:
31
- * - `breakpoint` — responsive layout changes (desktop ↔ mobile / tablet)
32
- * - `mapSize` — map container size variant changes
33
- * - `isMapReady` — plugins are enabled on `map:ready`, changing button visibility
34
- * - `isFullscreen` — fullscreen entry/exit changes which buttons are visible
35
- * - `appVisible` — app shown/hidden by parent HTML outside React (hybrid mode)
36
- *
37
- * When any of these change, `CLEAR_PLUGINS_EVALUATED` is dispatched (Effect 2),
38
- * which prevents the safe zone from being re-dispatched until
39
- * `useButtonStateEvaluator` has completed a full pass with no button state
40
- * changes and sets `PLUGINS_EVALUATED` again.
41
- *
42
- * ### Safe zone dispatch
43
- * Effect 3 fires whenever `arePluginsEvaluated` transitions to `true`, at which
44
- * point DOM dimensions are stable and `getSafeZoneInset` can be read reliably.
45
- * A `requestAnimationFrame` is used to ensure the browser has committed all
46
- * layout changes before measuring.
47
- *
48
- * ### Resize observer
49
- * Effect 4 keeps CSS custom properties up to date whenever any observed element
50
- * resizes (e.g. panels opening, banner appearing, actions buttons toggling).
51
- * It does not dispatch the safe zone — safe zone dispatch is owned entirely by
52
- * Effect 3 to prevent jumps on panel open/close and other non-structural resizes.
53
+ * Computes layout CSS vars for the map overlay and dispatches the safe zone inset used
54
+ * for `fitBounds`/`setView`. Waits for `arePluginsEvaluated` so the inset reflects final
55
+ * button visibility rather than a mid-evaluation state, which would make the map jump.
53
56
  */
54
- function calculateLayout (layoutRefs) {
57
+ function calculateLayout (layoutRefs, breakpoint) {
55
58
  const {
56
59
  appContainerRef, mainRef, topRef, topLeftColRef, topRightColRef,
57
60
  bottomRef, attributionsRef, bottomRightRef, leftTopRef, leftBottomRef,
58
- rightTopRef, rightBottomRef, actionsRef
61
+ rightTopRef, rightBottomRef, actionsRef, bannerRef, leftRef, rightRef
59
62
  } = layoutRefs
60
63
 
61
64
  const appContainer = appContainerRef.current
@@ -65,6 +68,7 @@ function calculateLayout (layoutRefs) {
65
68
  const topRightCol = topRightColRef.current
66
69
  const bottom = bottomRef.current
67
70
  const attributions = attributionsRef.current
71
+ const banner = bannerRef?.current
68
72
 
69
73
  if ([main, top, bottom].some(r => !r)) {
70
74
  return
@@ -72,41 +76,62 @@ function calculateLayout (layoutRefs) {
72
76
 
73
77
  const root = document.documentElement
74
78
  const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
79
+ const primaryGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--primary-gap'), 10)
75
80
 
76
81
  // === Top column width ===
77
- appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
82
+ const topColWidthPx = symmetricWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)
83
+ appContainer.style.setProperty('--top-col-width', `${topColWidthPx}px`)
84
+
85
+ // === Banner: docks centred between the side columns when there's room, otherwise
86
+ // stacks full-width. Mobile always stacks. ===
87
+ const isMobile = breakpoint === 'mobile'
88
+ if (isMobile) {
89
+ clearBannerPanelWidths(banner)
90
+ }
91
+ const bannerHeight = buttonHeight(bannerRef)
92
+ const hasBanner = bannerHeight > 0
93
+ const bannerSideColWidth = symmetricWidth(buttonWidth(leftRef), buttonWidth(rightRef))
94
+ const defaultPreferredWidth = Number.parseInt(getComputedStyle(root).getPropertyValue('--banner-preferred-width'), 10)
95
+ const preferredWidth = bannerConfiguredWidth(banner) ?? defaultPreferredWidth
96
+ appContainer.style.setProperty('--banner-preferred-width', `${preferredWidth}px`)
97
+ const gutterWidth = bannerGutterWidth(top.offsetWidth, bannerSideColWidth, dividerGap)
98
+ const isDocked = !isMobile && isBannerDocked(gutterWidth, preferredWidth)
99
+ banner?.classList.toggle(BANNER_DOCKED_CLASS, isDocked)
100
+
101
+ const bannerSideInset = `${bannerInset(isDocked, primaryGap, bannerSideColWidth, dividerGap)}px`
102
+ appContainer.style.setProperty('--banner-left', bannerSideInset)
103
+ appContainer.style.setProperty('--banner-right', bannerSideInset)
104
+
105
+ // Sits at the top row's bottom edge; top.offsetHeight already includes a trailing gap.
106
+ const isBannerStacked = hasBanner && !isDocked
107
+ const bannerTop = hasBanner ? top.offsetTop + top.offsetHeight : 0
108
+ appContainer.style.setProperty('--banner-top', `${bannerTop}px`)
109
+
110
+ // Stacked pushes the side columns below the banner plus a trailing gap — added here, not
111
+ // via a CSS last-child margin, since closed consumer HTML panels stay in the DOM (display:none).
112
+ const sideOffsetTop = (colHeight) => isBannerStacked
113
+ ? bannerTop + bannerHeight + dividerGap
114
+ : colHeight + top.offsetTop
78
115
 
79
116
  // === Left container offsets ===
80
- const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
117
+ const leftOffsetTop = sideOffsetTop(topLeftCol.offsetHeight)
81
118
  const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
82
119
  appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
83
120
  appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
84
121
  appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
85
122
 
86
- // === Right container offsets ===
87
- // Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
88
- // bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
89
- // the padding between the bottom of the bottom container and the bottom of main.
90
- const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
123
+ // === Right container offsets === (mirrors the left formula)
124
+ const bottomRightHeight = buttonHeight(bottomRightRef)
91
125
  const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
92
- const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
126
+ const rightOffsetTop = sideOffsetTop(topRightCol.offsetHeight)
93
127
  const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
94
128
  const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
95
- const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
96
129
  appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
97
- appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
130
+ appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom(bottomContainerPad, bottomRightHeight, attributions.offsetHeight, dividerGap)}px`)
98
131
  appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
99
132
 
100
133
  // === Keyboard hint bottom offset ===
101
- // On mobile the actions bar is in-flow so baseBottom already accounts for it.
102
- // On tablet/desktop the actions bar is position:absolute (not in flow), so baseBottom
103
- // only sees the bottom padding. actionsOffset measures the gap from the actions bar's
104
- // top edge to the bottom of main, ensuring the hint always clears the floating bar.
105
- const actionsEl = actionsRef?.current
106
- const actionsHeight = actionsEl?.offsetHeight ?? 0
107
- const baseBottom = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
108
- const actionsOffset = actionsHeight > 0 ? main.offsetHeight - actionsEl.offsetTop : 0
109
- appContainer.style.setProperty('--hint-bottom', `${Math.max(baseBottom, actionsOffset + dividerGap)}px`)
134
+ appContainer.style.setProperty('--hint-bottom', `${hintBottom(main, bottom, actionsRef?.current, dividerGap)}px`)
110
135
 
111
136
  // === Sub-slot panel max-heights ===
112
137
  appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
@@ -119,28 +144,20 @@ export function useLayoutMeasurements () {
119
144
  const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
120
145
  const { mapSize, isMapReady } = useMap()
121
146
 
122
- const { bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, actionsRef } = layoutRefs
147
+ const { bannerRef, mainRef, headerRef, topRef, topLeftColRef, topRightColRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, actionsRef, leftRef, rightRef } = layoutRefs
123
148
 
124
- // --------------------------------
125
- // 1. Clear the evaluated flag when structural inputs change so the safe zone
126
- // is not dispatched until useButtonStateEvaluator has completed a full
127
- // pass with the new app/map state and set PLUGINS_EVALUATED.
128
- // --------------------------------
149
+ // 1. Clear the evaluated flag on structural changes, gating the safe zone until re-evaluated.
129
150
  useLayoutEffect(() => {
130
151
  dispatch({ type: 'CLEAR_PLUGINS_EVALUATED' })
131
152
  }, [breakpoint, mapSize, isMapReady, appVisible, isFullscreen])
132
153
 
133
- // --------------------------------
134
- // 2. Once all plugin button props have been evaluated (arePluginsEvaluated),
135
- // recalculate layout and dispatch the safe zone inset.
136
- // RAF required to ensure browser layout is committed before measuring.
137
- // --------------------------------
154
+ // 2. Once evaluated, recalculate layout and dispatch the safe zone (RAF waits for layout to commit).
138
155
  useLayoutEffect(() => {
139
156
  if (!arePluginsEvaluated) {
140
157
  return
141
158
  }
142
159
  requestAnimationFrame(() => {
143
- calculateLayout(layoutRefs)
160
+ calculateLayout(layoutRefs, breakpoint)
144
161
  const safeZoneInset = getSafeZoneInset(layoutRefs)
145
162
  if (safeZoneInset) {
146
163
  dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } })
@@ -148,20 +165,14 @@ export function useLayoutMeasurements () {
148
165
  })
149
166
  }, [arePluginsEvaluated])
150
167
 
151
- // --------------------------------
152
- // 3. Recalculate CSS vars whenever observed elements resize (panels, banner,
153
- // actions buttons, etc.). Safe zone is intentionally not dispatched here —
154
- // that is Effect 2's responsibility.
155
- // --------------------------------
156
- // Stable reference — all entries are ref objects from useApp that never change.
157
- // Prevents useResizeObserver's effect from re-running (and cancelling its RAF)
158
- // on every render due to a new array literal being passed each time.
168
+ // 3. Recalculate CSS vars on resize; safe zone dispatch stays Effect 2's job.
169
+ // Memoized so useResizeObserver doesn't re-run (and cancel its RAF) on every render.
159
170
  const observedRefs = useMemo(
160
- () => [bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef],
171
+ () => [bannerRef, mainRef, headerRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, leftRef, rightRef],
161
172
  []
162
173
  )
163
174
 
164
175
  useResizeObserver(observedRefs, () => {
165
- calculateLayout(layoutRefs)
176
+ calculateLayout(layoutRefs, breakpoint)
166
177
  })
167
178
  }
@@ -20,10 +20,13 @@ const el = (props = {}) => {
20
20
  const refs = (o = {}) => ({
21
21
  appContainerRef: { current: o.appContainer || el() },
22
22
  mainRef: { current: o.main === null ? null : el({ offsetHeight: 500, ...o.main }) },
23
+ headerRef: { current: el(o.header) },
23
24
  bannerRef: { current: el(o.banner) },
24
25
  topRef: { current: o.top === null ? null : el({ offsetTop: 10, ...o.top }) },
25
26
  topLeftColRef: { current: el({ offsetHeight: 50, offsetWidth: 200, ...o.topLeftCol }) },
26
27
  topRightColRef: { current: el({ offsetHeight: 40, offsetWidth: 180, ...o.topRightCol }) },
28
+ leftRef: { current: el(o.left) },
29
+ rightRef: { current: el(o.right) },
27
30
  bottomRef: { current: o.bottom === null ? null : el({ offsetTop: 400, ...o.bottom }) },
28
31
  bottomRightRef: { current: el({ offsetTop: 400, ...o.bottomRight }) },
29
32
  leftTopRef: { current: el({ offsetHeight: 0, ...o.leftTop }) },
@@ -140,6 +143,63 @@ describe('useLayoutMeasurements', () => {
140
143
  expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--top-col-width', expected)
141
144
  })
142
145
 
146
+ test('clears inline banner panel widths on mobile', () => {
147
+ const { layoutRefs } = setup({ app: { breakpoint: 'mobile' } })
148
+ const panel = document.createElement('div')
149
+ panel.className = 'im-c-panel--banner'
150
+ panel.style.width = '250px'
151
+ layoutRefs.bannerRef.current.appendChild(panel)
152
+ renderHook(() => useLayoutMeasurements())
153
+ expect(panel.style.width).toBe('')
154
+ })
155
+
156
+ test('uses widest parseable banner panel width, ignoring unparseable ones', () => {
157
+ const { layoutRefs } = setup()
158
+ const banner = layoutRefs.bannerRef.current
159
+ const panel = (width) => {
160
+ const p = document.createElement('div')
161
+ p.className = 'im-c-panel--banner'
162
+ p.style.width = width
163
+ return p
164
+ }
165
+ banner.appendChild(panel('250px'))
166
+ banner.appendChild(panel('auto')) // unparseable, filtered out
167
+ banner.appendChild(panel('400px'))
168
+ renderHook(() => useLayoutMeasurements())
169
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--banner-preferred-width', '400px')
170
+ })
171
+
172
+ test('falls back to default preferred width when banner element is absent', () => {
173
+ const { layoutRefs } = setup()
174
+ layoutRefs.bannerRef.current = null
175
+ renderHook(() => useLayoutMeasurements())
176
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--banner-preferred-width', '8px')
177
+ })
178
+
179
+ test('docks the banner when the gutter is wide enough for the preferred width', () => {
180
+ const { layoutRefs } = setup({ refs: { top: { offsetWidth: 1000 } } })
181
+ renderHook(() => useLayoutMeasurements())
182
+ // docked: primaryGap (8) + sideColWidth (0) + dividerGap (8)
183
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--banner-left', '16px')
184
+ })
185
+
186
+ test('treats a null leftRef current as 0 width in the banner gutter calc', () => {
187
+ const { layoutRefs } = setup({ refs: { top: { offsetWidth: 1000 }, right: { offsetWidth: 20 } } })
188
+ layoutRefs.leftRef.current = null
189
+ renderHook(() => useLayoutMeasurements())
190
+ // symmetricWidth(0, 20) = 20; docked: primaryGap (8) + sideColWidth (20) + dividerGap (8)
191
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--banner-left', '36px')
192
+ })
193
+
194
+ test('stacks a present banner below the top row when not docked', () => {
195
+ const { layoutRefs } = setup({ refs: { banner: { offsetHeight: 30 } } })
196
+ renderHook(() => useLayoutMeasurements())
197
+ // bannerTop = top.offsetTop (10) + top.offsetHeight (0)
198
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--banner-top', '10px')
199
+ // stacked: left column pushed below the banner (bannerTop 10 + bannerHeight 30 + dividerGap 8)
200
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--left-offset-top', '48px')
201
+ })
202
+
143
203
  test('uses 0 when bottomRightRef current is null', () => {
144
204
  const { layoutRefs } = setup()
145
205
  layoutRefs.bottomRightRef.current = null
@@ -264,7 +324,7 @@ describe('useLayoutMeasurements', () => {
264
324
  const { layoutRefs } = setup()
265
325
  renderHook(() => useLayoutMeasurements())
266
326
  expect(useResizeObserver).toHaveBeenCalledWith(
267
- [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.bottomRef, layoutRefs.bottomRightRef, layoutRefs.leftTopRef, layoutRefs.leftBottomRef, layoutRefs.rightTopRef, layoutRefs.rightBottomRef, layoutRefs.drawerRef],
327
+ [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.headerRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.bottomRef, layoutRefs.bottomRightRef, layoutRefs.leftTopRef, layoutRefs.leftBottomRef, layoutRefs.rightTopRef, layoutRefs.rightBottomRef, layoutRefs.drawerRef, layoutRefs.leftRef, layoutRefs.rightRef],
268
328
  expect.any(Function)
269
329
  )
270
330
  layoutRefs.appContainerRef.current.style.setProperty.mockClear()
@@ -5,7 +5,7 @@ import { createButtonRegistry } from './registry/buttonRegistry.js'
5
5
  import { createPanelRegistry } from './registry/panelRegistry.js'
6
6
  import { createControlRegistry } from './registry/controlRegistry.js'
7
7
  import { createPluginRegistry } from './registry/pluginRegistry.js'
8
- import { setProviderSupportedShortcuts } from './registry/keyboardShortcutRegistry.js'
8
+ import { createKeyboardShortcutRegistry } from './registry/keyboardShortcutRegistry.js'
9
9
  import { mergeManifests } from './registry/mergeManifests.js'
10
10
  import { App } from './App.jsx'
11
11
 
@@ -19,13 +19,15 @@ const getOrCreateRegistries = (rootElement) => {
19
19
  const buttonRegistry = createButtonRegistry()
20
20
  const panelRegistry = createPanelRegistry()
21
21
  const controlRegistry = createControlRegistry()
22
+ const keyboardShortcutRegistry = createKeyboardShortcutRegistry()
22
23
  const pluginRegistry = createPluginRegistry({
23
24
  registerButton: buttonRegistry.registerButton,
24
25
  registerPanel: panelRegistry.registerPanel,
25
- registerControl: controlRegistry.registerControl
26
+ registerControl: controlRegistry.registerControl,
27
+ registerKeyboardShortcut: keyboardShortcutRegistry.registerKeyboardShortcut
26
28
  })
27
29
 
28
- registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry }
30
+ registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry }
29
31
  registryMap.set(rootElement, registries)
30
32
  }
31
33
  return registries
@@ -70,15 +72,15 @@ export async function initialiseApp (rootElement, {
70
72
  mapProviderMap.set(rootElement, mapProvider)
71
73
  }
72
74
 
75
+ // Reuse or create registries (persist across app open/close cycles)
76
+ const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry } = getOrCreateRegistries(rootElement)
77
+ const { registerPlugin } = pluginRegistry
78
+
73
79
  // Register provider-supported shortcuts
74
80
  if (mapProvider.capabilities?.supportedShortcuts) {
75
- setProviderSupportedShortcuts(mapProvider.capabilities.supportedShortcuts)
81
+ keyboardShortcutRegistry.setProviderSupportedShortcuts(mapProvider.capabilities.supportedShortcuts)
76
82
  }
77
83
 
78
- // Reuse or create registries (persist across app open/close cycles)
79
- const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry } = getOrCreateRegistries(rootElement)
80
- const { registerPlugin } = pluginRegistry
81
-
82
84
  // Clear previous plugins (but keep runtime additions)
83
85
  pluginRegistry.clear()
84
86
 
@@ -121,6 +123,7 @@ export async function initialiseApp (rootElement, {
121
123
  panelRegistry={panelRegistry}
122
124
  controlRegistry={controlRegistry}
123
125
  pluginRegistry={pluginRegistry}
126
+ keyboardShortcutRegistry={keyboardShortcutRegistry}
124
127
  mapProvider={mapProvider}
125
128
  />)
126
129
 
@@ -3,7 +3,6 @@ import { initialiseApp } from './initialiseApp.js'
3
3
  import { createRoot } from 'react-dom/client'
4
4
  import { EVENTS as events } from '../config/events.js'
5
5
  import { createMockRegistries } from '../test-utils.js'
6
- import { setProviderSupportedShortcuts } from './registry/keyboardShortcutRegistry.js'
7
6
 
8
7
  // --------------------------------------------------
9
8
  // Shared mocks
@@ -48,7 +47,7 @@ jest.mock('./registry/pluginRegistry.js', () => ({
48
47
  }))
49
48
 
50
49
  jest.mock('./registry/keyboardShortcutRegistry.js', () => ({
51
- setProviderSupportedShortcuts: jest.fn()
50
+ createKeyboardShortcutRegistry: jest.fn(() => mockRegistries.keyboardShortcutRegistry)
52
51
  }))
53
52
 
54
53
  jest.mock('./registry/mergeManifests.js', () => ({
@@ -179,6 +178,19 @@ describe('initialiseApp', () => {
179
178
 
180
179
  await initApp()
181
180
 
182
- expect(setProviderSupportedShortcuts).not.toHaveBeenCalled()
181
+ expect(mockRegistries.keyboardShortcutRegistry.setProviderSupportedShortcuts).not.toHaveBeenCalled()
182
+ })
183
+
184
+ test('registers provider-supported shortcuts on the per-instance registry', async () => {
185
+ await initApp()
186
+
187
+ expect(mockRegistries.keyboardShortcutRegistry.setProviderSupportedShortcuts).toHaveBeenCalledWith(['ctrl+a'])
188
+ })
189
+
190
+ test('passes keyboardShortcutRegistry through to the rendered App', async () => {
191
+ await initApp()
192
+
193
+ const rendered = createRoot.mock.results[0].value.render.mock.calls[0][0]
194
+ expect(rendered.props.keyboardShortcutRegistry).toBe(mockRegistries.keyboardShortcutRegistry)
183
195
  })
184
196
  })
@@ -16,7 +16,7 @@ import { getMapThemeVars } from '../../config/mapTheme.js'
16
16
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
17
17
  // sonarjs/disable-next-line function-name
18
18
  export const Layout = () => {
19
- const { id } = useConfig()
19
+ const { id, mapLabel, mapHintText } = useConfig()
20
20
  const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen } = useApp()
21
21
  const { mapStyle } = useMap()
22
22
 
@@ -37,33 +37,34 @@ export const Layout = () => {
37
37
  style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }}
38
38
  ref={layoutRefs.appContainerRef}
39
39
  >
40
+ {/* Leads with mapLabel so screen reader users can tell multiple maps on a page apart. */}
41
+ <div className='im-u-visually-hidden'>{mapLabel}. {mapHintText}</div>
40
42
  <Viewport />
41
43
  <div className={`im-o-app__overlay${isLayoutReady ? '' : ' im-o-app__overlay--not-ready'}`}>
42
44
  <div className='im-o-app__side' ref={layoutRefs.sideRef}>
43
45
  <SlotRenderer slot={layoutSlots.SIDE} />
44
46
  </div>
45
47
  <div className='im-o-app__main' ref={layoutRefs.mainRef}>
46
- {['mobile', 'tablet'].includes(breakpoint) && (
47
- <div className='im-o-app__banner' ref={layoutRefs.bannerRef}>
48
- <SlotRenderer slot={layoutSlots.BANNER} />
49
- </div>
50
- )}
48
+ <div className='im-o-app__header' ref={layoutRefs.headerRef}>
49
+ <SlotRenderer slot={layoutSlots.HEADER} />
50
+ </div>
51
51
  <div className='im-o-app__top' ref={layoutRefs.topRef}>
52
52
  <div className='im-o-app__top-col' ref={layoutRefs.topLeftColRef}>
53
53
  <SlotRenderer slot={layoutSlots.TOP_LEFT} />
54
54
  </div>
55
55
  <div className='im-o-app__top-col'>
56
56
  <SlotRenderer slot={layoutSlots.TOP_MIDDLE} />
57
- {['desktop'].includes(breakpoint) && (
58
- <div className='im-o-app__banner' ref={layoutRefs.bannerRef}>
59
- <SlotRenderer slot={layoutSlots.BANNER} />
60
- </div>
61
- )}
62
57
  </div>
63
58
  <div className='im-o-app__top-col' ref={layoutRefs.topRightColRef}>
64
59
  <SlotRenderer slot={layoutSlots.TOP_RIGHT} />
65
60
  </div>
66
61
  </div>
62
+ {/* Docked (centred in the top-column gutter) vs stacked (full-width, below the
63
+ whole top row) is decided in useLayoutMeasurements — same DOM position either
64
+ way, so reading/tab order stays identical across breakpoints and modes. */}
65
+ <div className='im-o-app__banner' ref={layoutRefs.bannerRef}>
66
+ <SlotRenderer slot={layoutSlots.BANNER} />
67
+ </div>
67
68
  <div className='im-o-app__left' ref={layoutRefs.leftRef}>
68
69
  <div className='im-o-app__left-top' ref={layoutRefs.leftTopRef}>
69
70
  <SlotRenderer slot={layoutSlots.LEFT_TOP} />
@@ -36,6 +36,7 @@ describe('Layout', () => {
36
36
  const mockRefs = {
37
37
  appContainerRef: React.createRef(),
38
38
  mainRef: React.createRef(),
39
+ headerRef: React.createRef(),
39
40
  bannerRef: React.createRef(),
40
41
  topRef: React.createRef(),
41
42
  topLeftColRef: React.createRef(),
@@ -89,6 +90,7 @@ describe('Layout', () => {
89
90
  expect(screen.getByTestId('attributions')).toBeInTheDocument()
90
91
 
91
92
  expect(screen.getByTestId('slot-side')).toBeInTheDocument()
93
+ expect(screen.getByTestId('slot-header')).toBeInTheDocument()
92
94
  expect(screen.getByTestId('slot-banner')).toBeInTheDocument()
93
95
  expect(screen.getByTestId('slot-top-left')).toBeInTheDocument()
94
96
  expect(screen.getByTestId('slot-bottom-right')).toBeInTheDocument()