@defra/interactive-map 0.0.40-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 (351) 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/assets/images/slot-map.svg +61 -262
  9. package/docs/examples/draw-tools.mdx +3 -3
  10. package/docs/plugins/draw.md +79 -16
  11. package/docs/plugins/search.md +1 -1
  12. package/govuk-prototype-kit.config.json +2 -0
  13. package/jest.setup.js +5 -0
  14. package/package.json +9 -1
  15. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  16. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  17. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  18. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  19. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  20. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  21. package/plugins/datasets/dist/css/index.css +1 -1
  22. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  23. package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
  24. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  25. package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
  26. package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
  27. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  28. package/plugins/datasets/dist/umd/index.js +1 -1
  29. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js +175 -0
  30. package/plugins/datasets/src/adapters/esri/registry/esriDataset.test.js +55 -0
  31. package/plugins/datasets/src/adapters/loadLayerAdapter.test.js +61 -0
  32. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +14 -0
  33. package/plugins/datasets/src/api/setOpacity.test.js +9 -0
  34. package/plugins/datasets/src/components/LayersMenu/LayersMenu.jsx +10 -25
  35. package/plugins/datasets/src/components/LayersMenu/LayersMenu.test.jsx +0 -42
  36. package/plugins/datasets/src/datasets.scss +0 -1
  37. package/plugins/datasets/src/fetch/createDynamicSource.js +135 -129
  38. package/plugins/datasets/src/fetch/createDynamicSource.test.js +318 -0
  39. package/plugins/datasets/src/fetch/fetchGeoJSON.test.js +83 -0
  40. package/plugins/datasets/src/index.test.js +36 -0
  41. package/plugins/datasets/src/initialise/DatasetsInit.jsx +17 -7
  42. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +262 -0
  43. package/plugins/datasets/src/initialise/initialiseDatasets.js +16 -4
  44. package/plugins/datasets/src/initialise/initialiseDatasets.test.js +42 -0
  45. package/plugins/datasets/src/manifest.js +0 -38
  46. package/plugins/datasets/src/manifest.test.js +49 -0
  47. package/plugins/datasets/src/reducers/datasetsToMenu.test.js +54 -1
  48. package/plugins/datasets/src/reducers/pluginState.js +3 -16
  49. package/plugins/datasets/src/reducers/pluginState.test.js +23 -0
  50. package/plugins/datasets/src/registry/dataset.js +13 -1
  51. package/plugins/datasets/src/registry/dataset.test.js +61 -0
  52. package/plugins/datasets/src/registry/datasetRegistry.js +12 -6
  53. package/plugins/datasets/src/registry/datasetRegistry.test.js +16 -4
  54. package/plugins/datasets/src/registry/isVisibleWhen.js +6 -3
  55. package/plugins/datasets/src/registry/isVisibleWhen.test.js +11 -1
  56. package/plugins/datasets/src/utils/bbox.test.js +125 -0
  57. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  58. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  59. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  60. package/plugins/draw/dist/esm/index.js +1 -1
  61. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +2 -1
  62. package/plugins/draw/dist/umd/im-draw-ml-adapter.js.LICENSE.txt +1 -0
  63. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  64. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  65. package/plugins/draw/dist/umd/index.js +1 -1
  66. package/plugins/draw/src/DrawInit.jsx +10 -2
  67. package/plugins/draw/src/DrawInit.test.jsx +51 -10
  68. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +91 -11
  69. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +246 -4
  70. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +109 -14
  71. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +174 -11
  72. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +12 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +9 -1
  74. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +24 -3
  75. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +194 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +457 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/__helpers__/harness.js +102 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +82 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.test.js +83 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +55 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.test.js +62 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.js +138 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.test.js +197 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.js +92 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.test.js +101 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.js +46 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.test.js +42 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +151 -0
  89. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +156 -0
  90. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +4 -27
  91. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +24 -39
  92. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +4 -5
  93. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +20 -23
  94. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +25 -5
  95. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +6 -36
  96. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -49
  97. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +3 -53
  98. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +8 -33
  99. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +29 -72
  100. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +10 -3
  101. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.js +158 -0
  102. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.test.js +314 -0
  103. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +6 -1
  104. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +21 -0
  105. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +6 -2
  106. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +12 -0
  107. package/plugins/draw/src/adapters/maplibre/styles.js +38 -11
  108. package/plugins/draw/src/adapters/maplibre/styles.test.js +39 -0
  109. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.js +79 -0
  110. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.test.js +135 -0
  111. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.js +42 -0
  112. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.test.js +85 -0
  113. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.js +34 -0
  114. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.test.js +78 -0
  115. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +16 -3
  116. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +21 -3
  117. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +59 -0
  118. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.test.js +73 -0
  119. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.js +33 -0
  120. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.test.js +70 -0
  121. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +27 -2
  122. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +93 -0
  123. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +9 -0
  124. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +19 -1
  125. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +34 -1
  126. package/plugins/draw/src/adapters/openlayers/core/styles.js +73 -1
  127. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +122 -3
  128. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +12 -0
  129. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +8 -2
  130. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +31 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +9 -5
  132. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +33 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +5 -3
  134. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +11 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +10 -3
  136. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +24 -1
  137. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +18 -13
  138. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +46 -1
  139. package/plugins/draw/src/adapters/openlayers/olDraw.js +11 -4
  140. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +48 -6
  141. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +156 -0
  142. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +246 -0
  143. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +267 -0
  144. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +327 -0
  145. package/plugins/draw/src/adapters/openlayers/point/pointDragInteraction.js +71 -0
  146. package/plugins/draw/src/adapters/openlayers/point/pointOps.js +34 -0
  147. package/plugins/draw/src/adapters/openlayers/point/pointOps.test.js +32 -0
  148. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.js +82 -0
  149. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.test.js +107 -0
  150. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.js +83 -0
  151. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.test.js +214 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +3 -1
  153. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +6 -9
  154. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +15 -4
  155. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +1 -1
  156. package/plugins/draw/src/api/addFeature.js +16 -1
  157. package/plugins/draw/src/api/addFeature.test.js +62 -5
  158. package/plugins/draw/src/api/editFeature.js +7 -4
  159. package/plugins/draw/src/api/editFeature.test.js +16 -0
  160. package/plugins/draw/src/api/newPoint.js +42 -0
  161. package/plugins/draw/src/api/newPoint.test.js +117 -0
  162. package/plugins/draw/src/api/setStyle.js +43 -0
  163. package/plugins/draw/src/api/setStyle.test.js +75 -0
  164. package/plugins/draw/src/events.js +17 -10
  165. package/plugins/draw/src/events.test.js +14 -0
  166. package/plugins/draw/src/manifest.js +18 -6
  167. package/plugins/draw/src/manifest.test.js +32 -1
  168. package/plugins/draw/src/reducer.test.js +7 -0
  169. package/plugins/draw/src/utils/stripInternalProperties.js +19 -0
  170. package/plugins/draw/src/utils/stripInternalProperties.test.js +50 -0
  171. package/plugins/draw/src/utils/symbolKeys.js +5 -0
  172. package/plugins/draw/src/validation/liveDrawChecks.test.js +54 -0
  173. package/plugins/draw/src/validation/liveStroke.test.js +64 -1
  174. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  175. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  176. package/plugins/interact/dist/umd/index.js +1 -1
  177. package/plugins/interact/src/hooks/useHighlightSync.js +33 -12
  178. package/plugins/interact/src/hooks/useHighlightSync.test.js +96 -25
  179. package/plugins/interact/src/utils/featureQueries.js +18 -0
  180. package/plugins/interact/src/utils/featureQueries.test.js +15 -0
  181. package/plugins/interact/src/utils/spatial.js +9 -3
  182. package/plugins/interact/src/utils/spatial.test.js +12 -1
  183. package/plugins/map-key/dist/css/index.css +62 -0
  184. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -0
  185. package/plugins/map-key/dist/esm/index.js +1 -0
  186. package/plugins/map-key/dist/umd/im-map-key-plugin.js +2 -0
  187. package/plugins/map-key/dist/umd/im-map-key-plugin.js.LICENSE.txt +1 -0
  188. package/plugins/map-key/dist/umd/index.js +2 -0
  189. package/plugins/map-key/dist/umd/index.js.LICENSE.txt +1 -0
  190. package/plugins/map-key/package.json +28 -0
  191. package/plugins/map-key/src/components/Key/EmptyKey.jsx +7 -0
  192. package/plugins/{datasets → map-key}/src/components/Key/EmptyKey.test.jsx +2 -2
  193. package/plugins/map-key/src/components/Key/Key.jsx +41 -0
  194. package/plugins/{datasets → map-key}/src/components/Key/Key.module.scss +15 -14
  195. package/plugins/map-key/src/components/Key/Key.test.jsx +73 -0
  196. package/plugins/map-key/src/components/Key/KeyGroupItem.jsx +16 -0
  197. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +53 -0
  198. package/plugins/map-key/src/components/Key/KeyItem.jsx +29 -0
  199. package/plugins/map-key/src/components/Key/KeyItem.test.jsx +67 -0
  200. package/plugins/map-key/src/components/Key/KeySvg.jsx +42 -0
  201. package/plugins/{datasets → map-key}/src/components/Key/KeySvg.test.jsx +14 -12
  202. package/plugins/map-key/src/components/Key/KeySvgLine.jsx +28 -0
  203. package/plugins/{datasets → map-key}/src/components/Key/KeySvgLine.test.jsx +1 -1
  204. package/plugins/map-key/src/components/Key/KeySvgPattern.jsx +23 -0
  205. package/plugins/{datasets → map-key}/src/components/Key/KeySvgPattern.test.jsx +2 -2
  206. package/plugins/map-key/src/components/Key/KeySvgRect.jsx +33 -0
  207. package/plugins/{datasets → map-key}/src/components/Key/KeySvgRect.test.jsx +1 -1
  208. package/plugins/map-key/src/components/Key/KeySvgSymbol.jsx +27 -0
  209. package/plugins/{datasets → map-key}/src/components/Key/KeySvgSymbol.test.jsx +5 -6
  210. package/plugins/map-key/src/components/Key/MapKey.jsx +50 -0
  211. package/plugins/map-key/src/components/Key/MapKey.test.jsx +130 -0
  212. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.js +2 -2
  213. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.test.js +2 -2
  214. package/plugins/map-key/src/index.js +13 -0
  215. package/plugins/map-key/src/index.test.js +31 -0
  216. package/plugins/map-key/src/initialise/MapKeyInit.jsx +15 -0
  217. package/plugins/map-key/src/initialise/MapKeyInit.test.jsx +39 -0
  218. package/plugins/map-key/src/manifest.js +30 -0
  219. package/plugins/map-key/src/manifest.test.js +63 -0
  220. package/plugins/map-key/src/mapKey.scss +4 -0
  221. package/plugins/map-key/src/registry/getDatasetRegistry.js +6 -0
  222. package/plugins/map-key/src/registry/getDatasetRegistry.test.js +23 -0
  223. package/plugins/map-key/src/registry/index.js +3 -0
  224. package/plugins/map-key/src/registry/index.test.js +15 -0
  225. package/plugins/menu/dist/css/index.css +80 -0
  226. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -0
  227. package/plugins/menu/dist/esm/index.js +1 -0
  228. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -0
  229. package/plugins/menu/dist/umd/index.js +2 -0
  230. package/plugins/menu/dist/umd/index.js.LICENSE.txt +1 -0
  231. package/plugins/menu/package.json +28 -0
  232. package/plugins/menu/src/components/Menu/CheckboxGroupWrapper.jsx +21 -0
  233. package/plugins/menu/src/components/Menu/GroupLegend.jsx +17 -0
  234. package/plugins/menu/src/components/Menu/GroupLegend.test.jsx +68 -0
  235. package/plugins/menu/src/components/Menu/Menu.jsx +19 -0
  236. package/plugins/menu/src/components/Menu/Menu.module.scss +90 -0
  237. package/plugins/menu/src/components/Menu/Menu.test.jsx +161 -0
  238. package/plugins/menu/src/components/Menu/MenuCheckbox.jsx +35 -0
  239. package/plugins/menu/src/components/Menu/MenuCheckbox.test.jsx +93 -0
  240. package/plugins/menu/src/components/Menu/MenuGroup.jsx +11 -0
  241. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.jsx → menu/src/components/Menu/MenuRadio.jsx} +6 -4
  242. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx → menu/src/components/Menu/MenuRadio.test.jsx} +18 -18
  243. package/plugins/menu/src/components/Menu/RadioGroupWrapper.jsx +32 -0
  244. package/plugins/{datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx → menu/src/components/Menu/RadioGroupWrapper.test.jsx} +21 -21
  245. package/plugins/menu/src/components/Menu/useIdPrefix.jsx +4 -0
  246. package/plugins/menu/src/index.js +13 -0
  247. package/plugins/menu/src/index.test.js +31 -0
  248. package/plugins/menu/src/initialise/MenuInit.jsx +24 -0
  249. package/plugins/menu/src/initialise/MenuInit.test.jsx +50 -0
  250. package/plugins/menu/src/initialise/createMenu.js +10 -0
  251. package/plugins/menu/src/initialise/createMenu.test.js +37 -0
  252. package/plugins/menu/src/manifest.js +35 -0
  253. package/plugins/menu/src/manifest.test.js +63 -0
  254. package/plugins/menu/src/menu.scss +4 -0
  255. package/plugins/menu/src/reducers/menuStateReducer.test.js +38 -0
  256. package/plugins/menu/src/reducers/pluginState.js +35 -0
  257. package/plugins/menu/src/reducers/pluginState.test.js +57 -0
  258. package/plugins/menu/src/registry/getDatasetRegistry.js +9 -0
  259. package/plugins/menu/src/registry/getDatasetRegistry.test.js +23 -0
  260. package/plugins/menu/src/registry/index.js +3 -0
  261. package/plugins/menu/src/registry/index.test.js +15 -0
  262. package/plugins/menu/src/registry/isVisibleWhen.js +43 -0
  263. package/plugins/menu/src/registry/isVisibleWhen.test.js +48 -0
  264. package/plugins/search/dist/css/index.css +1 -1
  265. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  266. package/plugins/search/dist/esm/index.js +1 -1
  267. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  268. package/plugins/search/dist/umd/index.js +1 -1
  269. package/plugins/search/src/Search.jsx +2 -3
  270. package/plugins/search/src/components/Form/Form.module.scss +9 -20
  271. package/plugins/search/src/index.js +0 -1
  272. package/plugins/search/src/index.test.js +2 -4
  273. package/plugins/search/src/manifest.js +1 -1
  274. package/plugins/search/src/search.scss +17 -16
  275. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  276. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  277. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  278. package/providers/beta/openlayers/src/openlayersProvider.js +4 -1
  279. package/providers/beta/openlayers/src/openlayersProvider.test.js +18 -0
  280. package/providers/beta/openlayers/src/utils/highlightFeatures.js +52 -5
  281. package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +176 -0
  282. package/providers/beta/openlayers/src/utils/symbolImages.js +40 -0
  283. package/providers/beta/openlayers/src/utils/symbolImages.test.js +58 -0
  284. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  285. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  286. package/providers/maplibre/dist/umd/index.js +1 -1
  287. package/providers/maplibre/src/utils/drawLayerBuckets.js +14 -0
  288. package/providers/maplibre/src/utils/drawLayerBuckets.test.js +18 -0
  289. package/providers/maplibre/src/utils/highlightFeatures.js +80 -55
  290. package/providers/maplibre/src/utils/highlightFeatures.test.js +156 -17
  291. package/providers/maplibre/src/utils/hoverCursor.js +30 -14
  292. package/providers/maplibre/src/utils/hoverCursor.test.js +35 -0
  293. package/providers/maplibre/src/utils/patternImages.js +1 -1
  294. package/providers/maplibre/src/utils/symbolImages.js +41 -3
  295. package/providers/maplibre/src/utils/symbolImages.test.js +40 -6
  296. package/rollup.esm.mjs +11 -1
  297. package/src/App/components/Actions/Actions.test.jsx +47 -0
  298. package/src/App/components/Hints/Hints.test.jsx +13 -0
  299. package/src/App/components/MoveControls/MoveControls.module.scss +41 -10
  300. package/src/App/components/Panel/Panel.jsx +18 -3
  301. package/src/App/components/Panel/Panel.module.scss +18 -8
  302. package/src/App/components/Panel/Panel.test.jsx +34 -0
  303. package/src/App/components/Tooltip/Tooltip.test.jsx +12 -14
  304. package/src/App/hooks/useFeatureItems.js +16 -7
  305. package/src/App/hooks/useFeatureItems.test.js +57 -0
  306. package/src/App/hooks/useLayoutMeasurements.js +95 -84
  307. package/src/App/hooks/useLayoutMeasurements.test.js +61 -1
  308. package/src/App/layout/Layout.jsx +9 -10
  309. package/src/App/layout/Layout.test.jsx +2 -0
  310. package/src/App/layout/layout.module.scss +31 -17
  311. package/src/App/registry/pluginRegistry.js +1 -1
  312. package/src/App/registry/pluginRegistry.test.js +12 -0
  313. package/src/App/renderer/HtmlElementHost.jsx +18 -5
  314. package/src/App/renderer/HtmlElementHost.test.jsx +54 -0
  315. package/src/App/renderer/mapButtons.js +28 -12
  316. package/src/App/renderer/mapButtons.test.js +17 -4
  317. package/src/App/renderer/mapControls.js +3 -1
  318. package/src/App/renderer/mapControls.test.js +16 -0
  319. package/src/App/renderer/mapPanels.js +44 -9
  320. package/src/App/renderer/mapPanels.test.js +51 -2
  321. package/src/App/renderer/orderItems.js +28 -0
  322. package/src/App/renderer/orderItems.test.js +50 -0
  323. package/src/App/renderer/slotAggregator.js +2 -18
  324. package/src/App/renderer/slotHelpers.js +3 -1
  325. package/src/App/renderer/slotHelpers.test.js +5 -0
  326. package/src/App/renderer/slots.js +3 -0
  327. package/src/App/store/AppProvider.jsx +1 -0
  328. package/src/App/store/appDispatchMiddleware.js +1 -1
  329. package/src/App/store/appDispatchMiddleware.test.js +8 -0
  330. package/src/config/events.js +2 -0
  331. package/src/scss/settings/_colors.scss +1 -0
  332. package/src/scss/settings/_dimensions.scss +2 -3
  333. package/src/scss/settings/_transition.scss +1 -1
  334. package/src/services/eventBus.js +44 -0
  335. package/src/services/eventBus.test.js +83 -0
  336. package/src/services/symbolRegistry.js +1 -1
  337. package/src/types.js +11 -1
  338. package/webpack.umd.mjs +2 -0
  339. package/plugins/datasets/src/components/Key/EmptyKey.jsx +0 -7
  340. package/plugins/datasets/src/components/Key/Key.jsx +0 -50
  341. package/plugins/datasets/src/components/Key/KeyGroupItem.jsx +0 -18
  342. package/plugins/datasets/src/components/Key/KeyItem.jsx +0 -23
  343. package/plugins/datasets/src/components/Key/KeySvg.jsx +0 -22
  344. package/plugins/datasets/src/components/Key/KeySvgLine.jsx +0 -19
  345. package/plugins/datasets/src/components/Key/KeySvgPattern.jsx +0 -13
  346. package/plugins/datasets/src/components/Key/KeySvgRect.jsx +0 -22
  347. package/plugins/datasets/src/components/Key/KeySvgSymbol.jsx +0 -16
  348. package/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx +0 -41
  349. /package/plugins/{datasets → menu}/src/reducers/menuStateReducer.js +0 -0
  350. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.js +0 -0
  351. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.test.js +0 -0
@@ -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()
@@ -45,27 +45,26 @@ export const Layout = () => {
45
45
  <SlotRenderer slot={layoutSlots.SIDE} />
46
46
  </div>
47
47
  <div className='im-o-app__main' ref={layoutRefs.mainRef}>
48
- {['mobile', 'tablet'].includes(breakpoint) && (
49
- <div className='im-o-app__banner' ref={layoutRefs.bannerRef}>
50
- <SlotRenderer slot={layoutSlots.BANNER} />
51
- </div>
52
- )}
48
+ <div className='im-o-app__header' ref={layoutRefs.headerRef}>
49
+ <SlotRenderer slot={layoutSlots.HEADER} />
50
+ </div>
53
51
  <div className='im-o-app__top' ref={layoutRefs.topRef}>
54
52
  <div className='im-o-app__top-col' ref={layoutRefs.topLeftColRef}>
55
53
  <SlotRenderer slot={layoutSlots.TOP_LEFT} />
56
54
  </div>
57
55
  <div className='im-o-app__top-col'>
58
56
  <SlotRenderer slot={layoutSlots.TOP_MIDDLE} />
59
- {['desktop'].includes(breakpoint) && (
60
- <div className='im-o-app__banner' ref={layoutRefs.bannerRef}>
61
- <SlotRenderer slot={layoutSlots.BANNER} />
62
- </div>
63
- )}
64
57
  </div>
65
58
  <div className='im-o-app__top-col' ref={layoutRefs.topRightColRef}>
66
59
  <SlotRenderer slot={layoutSlots.TOP_RIGHT} />
67
60
  </div>
68
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>
69
68
  <div className='im-o-app__left' ref={layoutRefs.leftRef}>
70
69
  <div className='im-o-app__left-top' ref={layoutRefs.leftTopRef}>
71
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()
@@ -76,16 +76,41 @@
76
76
  }
77
77
 
78
78
  // ---------------------------------------------------
79
- // Banner: Panels only
79
+ // Banner: docks centred between the side columns or stacks full-width; mode and position are set in JS.
80
80
  // ---------------------------------------------------
81
81
 
82
82
  .im-o-app__banner {
83
+ position: absolute;
84
+ top: var(--banner-top);
85
+ left: var(--banner-left);
86
+ right: var(--banner-right);
87
+ z-index: 9;
83
88
  display: flex;
84
89
  flex-direction: column;
85
90
  gap: var(--divider-gap);
86
91
  }
87
92
 
88
- .im-o-app__banner .im-c-panel:last-child {
93
+ // Trailing gap below the banner is added in JS, not via a last-child margin, since closed consumer HTML panels stay in the DOM.
94
+
95
+ // Stacked fills full width; docked caps the width and centres it.
96
+ .im-o-app__banner--docked .im-c-panel--banner {
97
+ max-width: var(--banner-preferred-width);
98
+ margin-inline: auto;
99
+ }
100
+
101
+ // ---------------------------------------------------
102
+ // Header: normal flow, above the top row — pushes everything below it down for free.
103
+ // ---------------------------------------------------
104
+
105
+ // z-index only, no position — flex items get stacking priority from z-index alone, and must stay above .im-o-app__top's since header comes first in the DOM.
106
+ .im-o-app__header {
107
+ z-index: 11;
108
+ display: flex;
109
+ flex-direction: column;
110
+ width: 100%;
111
+ }
112
+
113
+ .im-o-app__header:not(:empty) {
89
114
  margin-bottom: var(--divider-gap);
90
115
  }
91
116
 
@@ -93,7 +118,9 @@
93
118
  // Top: Includes a panel and buttons in colums
94
119
  // ---------------------------------------------------
95
120
 
121
+ // z-index only (see .im-o-app__header's comment above) — must stay below its value.
96
122
  .im-o-app__top {
123
+ z-index: 10;
97
124
  display: flex;
98
125
  margin-bottom: auto;
99
126
  justify-content: space-between;
@@ -421,10 +448,6 @@
421
448
  .im-o-app__modal .im-c-panel--banner {
422
449
  margin: var(--primary-gap);
423
450
  }
424
-
425
- .im-o-app__banner {
426
- z-index: 9;
427
- }
428
451
  }
429
452
 
430
453
  // Desktop modal banner margin
@@ -447,13 +470,6 @@
447
470
  opacity: 0;
448
471
  }
449
472
 
450
- // Banner on desktop
451
- .im-o-app--desktop .im-c-panel--banner {
452
- margin-left: auto;
453
- margin-right: auto;
454
- max-width: var(--banner-max-desktop-width);
455
- }
456
-
457
473
  // Actions inset when width is greater
458
474
  .im-o-app--tablet, .im-o-app--desktop {
459
475
  .im-o-app__actions {
@@ -466,8 +482,7 @@
466
482
  max-width: var(--action-bar-max-width);
467
483
  }
468
484
 
469
- // max-height animation on a bottom-anchored element grows upward, looking like
470
- // a slide-up. Override to opacity-only so the bar fades in instead.
485
+ // Opacity-only so it fades in instead of visually sliding up.
471
486
  @media (prefers-reduced-motion: no-preference) {
472
487
  .im-c-actions {
473
488
  transition: opacity var(--duration) ease;
@@ -498,8 +513,7 @@
498
513
  clip-path: inset(-20px 0 0 0);
499
514
  }
500
515
 
501
- // Inset focus ring on panels that are flush with the container edge,
502
- // so the ::after ring isn't clipped by the parent overflow:hidden
516
+ // Inset the focus ring so it isn't clipped by the parent's overflow:hidden.
503
517
  .im-o-app--mobile .im-o-app__drawer .im-c-panel::after,
504
518
  .im-o-app--mobile .im-o-app__actions .im-c-panel::after,
505
519
  .im-o-app__modal .im-c-panel--drawer::after {
@@ -11,7 +11,7 @@ function validateSlots (item, type) {
11
11
  const allowed = allowedSlots[type]
12
12
  BREAKPOINTS.forEach(bp => {
13
13
  const slot = item[bp]?.slot
14
- if (slot && !allowed.includes(slot) && !(type === 'panel' && slot.endsWith('-button'))) {
14
+ if (slot && !allowed.includes(slot) && !(type === 'panel' && slot.endsWith('-button')) && !(type === 'control' && slot.endsWith('-panel'))) {
15
15
  logger.warn(`${type} "${item.id}" has invalid slot "${slot}" at breakpoint "${bp}". Allowed slots: ${allowed.join(', ')}.`)
16
16
  }
17
17
  })
@@ -171,6 +171,18 @@ describe('pluginRegistry', () => {
171
171
  pluginRegistry.registerPlugin(plugin)
172
172
  expect(console.warn).not.toHaveBeenCalled()
173
173
  })
174
+
175
+ it('does not warn for a control targeting a panel-body slot', () => {
176
+ const plugin = {
177
+ id: 'inject-plugin',
178
+ config: {},
179
+ manifest: {
180
+ controls: [{ id: 'ctrl1', desktop: { slot: 'map-styles-panel' } }]
181
+ }
182
+ }
183
+ pluginRegistry.registerPlugin(plugin)
184
+ expect(console.warn).not.toHaveBeenCalled()
185
+ })
174
186
  })
175
187
 
176
188
  it('clears all registered plugins', () => {
@@ -28,6 +28,10 @@ export const getSlotRef = (slot, layoutRefs) => {
28
28
  const el = document.querySelector(`[data-button-slot="${slot}"]`)
29
29
  return el ? { current: el } : null
30
30
  }
31
+ if (slot?.endsWith('-panel')) {
32
+ const el = document.querySelector(`[data-panel-slot="${slot}"]`)
33
+ return el ? { current: el } : null
34
+ }
31
35
 
32
36
  return slotRefMap[slot] || null
33
37
  }
@@ -37,8 +41,13 @@ export const getSlotRef = (slot, layoutRefs) => {
37
41
  * Moves the wrapper into the target slot when visible, hides it otherwise.
38
42
  * Depends on breakpoint to handle conditionally rendered slot containers
39
43
  * (e.g. the banner slot swaps DOM nodes between mobile and desktop).
44
+ *
45
+ * `anchorKey` is an optional extra dependency for anchors that aren't always present in the
46
+ * DOM — e.g. a `-panel` target only exists while that panel is open — so the projection is
47
+ * re-attempted whenever it changes, rather than only on mount. Stable/persistent anchors
48
+ * (named layout slots, `-button` targets) don't need it since they never come and go.
40
49
  */
41
- export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint) => {
50
+ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint, anchorKey) => {
42
51
  const layoutRefsRef = useRef(layoutRefs)
43
52
  layoutRefsRef.current = layoutRefs
44
53
 
@@ -66,7 +75,7 @@ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs,
66
75
  return () => {
67
76
  wrapper.style.display = 'none'
68
77
  }
69
- }, [isVisible, targetSlot, breakpoint, wrapperRef])
78
+ }, [isVisible, targetSlot, breakpoint, wrapperRef, anchorKey])
70
79
  }
71
80
 
72
81
  /**
@@ -126,13 +135,17 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen,
126
135
  */
127
136
  const PersistentControl = ({ control, appState }) => {
128
137
  const wrapperRef = useRef(null)
129
- const { breakpoint, mode, isFullscreen, layoutRefs } = appState
138
+ const { breakpoint, mode, isFullscreen, layoutRefs, openPanels } = appState
130
139
 
131
140
  const bpConfig = control[breakpoint]
132
141
  const isVisible = isControlVisible(control, { breakpoint, mode, isFullscreen })
133
142
  const targetSlot = bpConfig?.slot || null
134
143
 
135
- useDomProjection(wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint)
144
+ // A control targeting a panel's body (`<panelId>-panel`) needs its DOM anchor re-resolved
145
+ // whenever panels open/close, since that anchor only exists while the target panel is open.
146
+ const anchorKey = targetSlot?.endsWith('-panel') ? Object.keys(openPanels || {}).sort((a, b) => a.localeCompare(b)).join(',') : null
147
+
148
+ useDomProjection(wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint, anchorKey)
136
149
 
137
150
  const innerHtml = useMemo(() => ({ __html: control.html }), [control.html])
138
151
 
@@ -174,7 +187,7 @@ export const HtmlElementHost = () => {
174
187
  const cfg = panelConfig[panelId]?.[breakpoint]
175
188
  return cfg?.modal
176
189
  })
177
- return modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null
190
+ return modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null // NOSONAR - .at() unsupported on Chrome < 92
178
191
  }, [openPanels, panelConfig, breakpoint])
179
192
 
180
193
  if (!htmlPanels.length && !htmlControls.length) {
@@ -313,6 +313,60 @@ describe('HtmlElementHost', () => {
313
313
  expect(getSlotRef('nonexistent-button', {})).toBeNull()
314
314
  })
315
315
 
316
+ test('getSlotRef returns wrapped element for panel slot when element exists', () => {
317
+ const el = document.createElement('div')
318
+ el.dataset.panelSlot = 'map-styles-panel'
319
+ document.body.appendChild(el)
320
+ expect(getSlotRef('map-styles-panel', {})).toEqual({ current: el })
321
+ el.remove()
322
+ })
323
+
324
+ test('getSlotRef returns null for panel slot when no element found', () => {
325
+ expect(getSlotRef('nonexistent-panel', {})).toBeNull()
326
+ })
327
+
328
+ it('re-attempts DOM projection into a panel-body slot once its anchor appears (panel opens)', () => {
329
+ const controlConfig = {
330
+ mapStylesNote: { id: 'mapStylesNote', desktop: { slot: 'map-styles-panel' }, html: '<p>Injected note</p>' }
331
+ }
332
+
333
+ // A real React-rendered node, mimicking the real Panel.jsx body div — present in the DOM
334
+ // throughout, but only gains the data-panel-slot marker once the panel "opens".
335
+ const panelBodyRef = React.createRef()
336
+
337
+ mockApp({ controlConfig, openPanels: {} })
338
+ const { rerender } = render(
339
+ <div>
340
+ <div ref={panelBodyRef} />
341
+ <HtmlElementHost />
342
+ </div>
343
+ )
344
+
345
+ // Anchor isn't marked yet (panel closed) — nothing to project into
346
+ expect(panelBodyRef.current.querySelector('.im-c-control')).toBeNull()
347
+
348
+ // Panel opens: its body anchor is marked, and openPanels changes (two entries, so the
349
+ // anchorKey sort's localeCompare comparator actually runs)
350
+ panelBodyRef.current.setAttribute('data-panel-slot', 'map-styles-panel')
351
+ mockApp({ controlConfig, openPanels: { mapStyles: { props: {} }, other: { props: {} } } })
352
+ rerender(
353
+ <div>
354
+ <div ref={panelBodyRef} />
355
+ <HtmlElementHost />
356
+ </div>
357
+ )
358
+
359
+ expect(panelBodyRef.current.querySelector('.im-c-control')).toBeTruthy()
360
+ })
361
+
362
+ it('does not crash a panel-targeting control when appState.openPanels is missing', () => {
363
+ const controlConfig = {
364
+ mapStylesNote: { id: 'mapStylesNote', desktop: { slot: 'map-styles-panel' }, html: '<p>Injected note</p>' }
365
+ }
366
+ mockApp({ controlConfig, openPanels: undefined })
367
+ expect(() => render(<HtmlElementHost />)).not.toThrow()
368
+ })
369
+
316
370
  it('does not append child if slotRef exists but current is null', () => {
317
371
  // 1. Setup refs where the slot exists in the map but the DOM node (current) is null
318
372
  const incompleteRefs = {