@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
@@ -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 {
@@ -1,15 +1,10 @@
1
1
  // src/core/registry/keyboardShortcutRegistry.js
2
2
  import { coreShortcuts } from '../controls/keyboardShortcuts.js'
3
3
 
4
- // Stores the actual shortcut objects in insertion order
5
- const pluginShortcutHelp = []
4
+ // State is passed in explicitly, rather than held at module scope, so each map instance gets its own registry and can't leak shortcuts or provider support into another map's help panel.
6
5
 
7
- // Tracks only IDs for O(1) duplicate detection
8
- const pluginShortcutIds = new Set()
9
-
10
- let providerSupportedIds = new Set()
11
-
12
- export const registerKeyboardShortcut = ({ shortcut }) => {
6
+ const _registerKeyboardShortcut = (state, { shortcut }) => {
7
+ const { pluginShortcutHelp, pluginShortcutIds } = state
13
8
  if (pluginShortcutIds.has(shortcut.id)) {
14
9
  pluginShortcutHelp[pluginShortcutHelp.findIndex(s => s.id === shortcut.id)] = shortcut
15
10
  } else {
@@ -18,14 +13,14 @@ export const registerKeyboardShortcut = ({ shortcut }) => {
18
13
  }
19
14
  }
20
15
 
21
- export const setProviderSupportedShortcuts = (ids = []) => {
22
- providerSupportedIds = new Set(ids)
16
+ const _setProviderSupportedShortcuts = (state, ids = []) => {
17
+ state.providerSupportedIds = new Set(ids)
23
18
  }
24
19
 
25
- export const getKeyboardShortcuts = (appConfig = {}) => {
20
+ const _getKeyboardShortcuts = (state, appConfig = {}) => {
26
21
  const filteredCore = coreShortcuts.filter(s => {
27
22
  // Must be supported by provider
28
- if (!providerSupportedIds.has(s.id)) {
23
+ if (!state.providerSupportedIds.has(s.id)) {
29
24
  return false
30
25
  }
31
26
  // Check requiredConfig - all specified config values must be truthy
@@ -37,6 +32,23 @@ export const getKeyboardShortcuts = (appConfig = {}) => {
37
32
 
38
33
  return [
39
34
  ...filteredCore, // supported core shortcuts
40
- ...pluginShortcutHelp // plugin-defined shortcuts (deduped)
35
+ ...state.pluginShortcutHelp // plugin-defined shortcuts (deduped)
41
36
  ]
42
37
  }
38
+
39
+ // One instance per map — see initialiseApp.js's getOrCreateRegistries.
40
+ export function createKeyboardShortcutRegistry () {
41
+ const state = {
42
+ // Stores the actual shortcut objects in insertion order
43
+ pluginShortcutHelp: [],
44
+ // Tracks only IDs for O(1) duplicate detection
45
+ pluginShortcutIds: new Set(),
46
+ providerSupportedIds: new Set()
47
+ }
48
+
49
+ return {
50
+ registerKeyboardShortcut: (args) => _registerKeyboardShortcut(state, args),
51
+ setProviderSupportedShortcuts: (ids) => _setProviderSupportedShortcuts(state, ids),
52
+ getKeyboardShortcuts: (appConfig) => _getKeyboardShortcuts(state, appConfig)
53
+ }
54
+ }
@@ -1,8 +1,6 @@
1
1
  // src/core/registry/keyboardShortcutRegistry.test.js
2
- describe('keyboardShortcutRegistry', () => {
3
- let registerKeyboardShortcut
4
- let setProviderSupportedShortcuts
5
- let getKeyboardShortcuts
2
+ describe('createKeyboardShortcutRegistry', () => {
3
+ let createKeyboardShortcutRegistry
6
4
  let coreShortcutsMock
7
5
 
8
6
  beforeEach(() => {
@@ -16,57 +14,55 @@ describe('keyboardShortcutRegistry', () => {
16
14
  coreShortcuts: coreShortcutsMock
17
15
  }))
18
16
 
19
- const module = require('./keyboardShortcutRegistry.js')
20
- registerKeyboardShortcut = module.registerKeyboardShortcut
21
- setProviderSupportedShortcuts = module.setProviderSupportedShortcuts
22
- getKeyboardShortcuts = module.getKeyboardShortcuts
17
+ createKeyboardShortcutRegistry = require('./keyboardShortcutRegistry.js').createKeyboardShortcutRegistry
23
18
  })
24
19
 
25
20
  test('registerKeyboardShortcut should add a plugin shortcut', () => {
21
+ const registry = createKeyboardShortcutRegistry()
26
22
  const shortcut = { id: 'pluginShortcut', description: 'Plugin Shortcut' }
27
- registerKeyboardShortcut({ shortcut }) // Pass { shortcut }
28
- const shortcuts = getKeyboardShortcuts()
29
- expect(shortcuts).toContain(shortcut)
23
+ registry.registerKeyboardShortcut({ shortcut })
24
+ expect(registry.getKeyboardShortcuts()).toContain(shortcut)
30
25
  })
31
26
 
32
27
  test('registerKeyboardShortcut updates an existing shortcut when re-registered with the same id', () => {
28
+ const registry = createKeyboardShortcutRegistry()
33
29
  const shortcut = { id: 'duplicate', description: 'First' }
34
30
  const updatedShortcut = { id: 'duplicate', description: 'Second' }
35
- registerKeyboardShortcut({ shortcut })
36
- registerKeyboardShortcut({ shortcut: updatedShortcut })
37
- const shortcuts = getKeyboardShortcuts()
31
+ registry.registerKeyboardShortcut({ shortcut })
32
+ registry.registerKeyboardShortcut({ shortcut: updatedShortcut })
33
+ const shortcuts = registry.getKeyboardShortcuts()
38
34
  expect(shortcuts).toContain(updatedShortcut)
39
35
  expect(shortcuts).not.toContain(shortcut)
40
36
  expect(shortcuts.filter(s => s.id === 'duplicate')).toHaveLength(1)
41
37
  })
42
38
 
43
39
  test('setProviderSupportedShortcuts should filter core shortcuts', () => {
44
- setProviderSupportedShortcuts(['copy'])
45
- const shortcuts = getKeyboardShortcuts()
46
- expect(shortcuts).toEqual([{ id: 'copy', description: 'Copy' }])
40
+ const registry = createKeyboardShortcutRegistry()
41
+ registry.setProviderSupportedShortcuts(['copy'])
42
+ expect(registry.getKeyboardShortcuts()).toEqual([{ id: 'copy', description: 'Copy' }])
47
43
  })
48
44
 
49
45
  test('setProviderSupportedShortcuts with no argument defaults to empty set', () => {
50
- setProviderSupportedShortcuts() // no arguments
51
- const shortcuts = getKeyboardShortcuts()
52
- expect(shortcuts).toEqual([]) // default empty
46
+ const registry = createKeyboardShortcutRegistry()
47
+ registry.setProviderSupportedShortcuts() // no ids argument
48
+ expect(registry.getKeyboardShortcuts()).toEqual([]) // default empty
53
49
  })
54
50
 
55
51
  test('getKeyboardShortcuts should merge core and plugin shortcuts', () => {
56
- setProviderSupportedShortcuts(['copy'])
52
+ const registry = createKeyboardShortcutRegistry()
53
+ registry.setProviderSupportedShortcuts(['copy'])
57
54
  const pluginShortcut = { id: 'plugin', description: 'Plugin' }
58
- registerKeyboardShortcut({ shortcut: pluginShortcut })
59
- const shortcuts = getKeyboardShortcuts()
60
- expect(shortcuts).toEqual([
55
+ registry.registerKeyboardShortcut({ shortcut: pluginShortcut })
56
+ expect(registry.getKeyboardShortcuts()).toEqual([
61
57
  { id: 'copy', description: 'Copy' },
62
58
  pluginShortcut
63
59
  ])
64
60
  })
65
61
 
66
62
  test('setProviderSupportedShortcuts with empty array returns no core shortcuts', () => {
67
- setProviderSupportedShortcuts([])
68
- const shortcuts = getKeyboardShortcuts()
69
- expect(shortcuts).toEqual([])
63
+ const registry = createKeyboardShortcutRegistry()
64
+ registry.setProviderSupportedShortcuts([])
65
+ expect(registry.getKeyboardShortcuts()).toEqual([])
70
66
  })
71
67
 
72
68
  test('getKeyboardShortcuts filters by requiredConfig when appConfig provided', () => {
@@ -77,23 +73,44 @@ describe('keyboardShortcutRegistry', () => {
77
73
  { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] }
78
74
  ]
79
75
  }))
80
- const module = require('./keyboardShortcutRegistry.js')
81
- module.setProviderSupportedShortcuts(['always', 'conditional'])
76
+ const registry = require('./keyboardShortcutRegistry.js').createKeyboardShortcutRegistry()
77
+ registry.setProviderSupportedShortcuts(['always', 'conditional'])
82
78
 
83
79
  // Without config, conditional shortcut is excluded
84
- expect(module.getKeyboardShortcuts()).toEqual([
80
+ expect(registry.getKeyboardShortcuts()).toEqual([
85
81
  { id: 'always', description: 'Always shown' }
86
82
  ])
87
83
 
88
84
  // With config false, conditional shortcut is excluded
89
- expect(module.getKeyboardShortcuts({ featureEnabled: false })).toEqual([
85
+ expect(registry.getKeyboardShortcuts({ featureEnabled: false })).toEqual([
90
86
  { id: 'always', description: 'Always shown' }
91
87
  ])
92
88
 
93
89
  // With config true, conditional shortcut is included
94
- expect(module.getKeyboardShortcuts({ featureEnabled: true })).toEqual([
90
+ expect(registry.getKeyboardShortcuts({ featureEnabled: true })).toEqual([
95
91
  { id: 'always', description: 'Always shown' },
96
92
  { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] }
97
93
  ])
98
94
  })
95
+
96
+ test('two registry instances are fully isolated from each other', () => {
97
+ // This is the multi-map-on-a-page scenario: a shortcut registered by one map
98
+ // (e.g. a plugin) must never leak into another map's help panel, and each map's
99
+ // provider-supported-shortcuts filtering must stay independent.
100
+ const registryA = createKeyboardShortcutRegistry()
101
+ const registryB = createKeyboardShortcutRegistry()
102
+
103
+ registryA.setProviderSupportedShortcuts(['copy'])
104
+ registryA.registerKeyboardShortcut({ shortcut: { id: 'onlyOnA', description: 'Only on A' } })
105
+
106
+ registryB.setProviderSupportedShortcuts(['paste'])
107
+
108
+ expect(registryA.getKeyboardShortcuts()).toEqual([
109
+ { id: 'copy', description: 'Copy' },
110
+ { id: 'onlyOnA', description: 'Only on A' }
111
+ ])
112
+ expect(registryB.getKeyboardShortcuts()).toEqual([
113
+ { id: 'paste', description: 'Paste' }
114
+ ])
115
+ })
99
116
  })
@@ -1,6 +1,5 @@
1
1
  // src/core/registry/pluginRegistry.js
2
2
  import { registerIcon } from './iconRegistry.js'
3
- import { registerKeyboardShortcut } from './keyboardShortcutRegistry.js'
4
3
  import { allowedSlots } from '../renderer/slots.js'
5
4
  import { logger } from '../../services/logger.js'
6
5
 
@@ -12,13 +11,13 @@ function validateSlots (item, type) {
12
11
  const allowed = allowedSlots[type]
13
12
  BREAKPOINTS.forEach(bp => {
14
13
  const slot = item[bp]?.slot
15
- 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'))) {
16
15
  logger.warn(`${type} "${item.id}" has invalid slot "${slot}" at breakpoint "${bp}". Allowed slots: ${allowed.join(', ')}.`)
17
16
  }
18
17
  })
19
18
  }
20
19
 
21
- export function createPluginRegistry ({ registerButton, registerPanel, registerControl }) {
20
+ export function createPluginRegistry ({ registerButton, registerPanel, registerControl, registerKeyboardShortcut }) {
22
21
  const registeredPlugins = []
23
22
 
24
23
  function registerPlugin (plugin) {
@@ -5,7 +5,6 @@ jest.mock('./buttonRegistry.js', () => ({ registerButton: jest.fn() }))
5
5
  jest.mock('./panelRegistry.js', () => ({ registerPanel: jest.fn() }))
6
6
  jest.mock('./controlRegistry.js', () => ({ registerControl: jest.fn() }))
7
7
  jest.mock('./iconRegistry.js', () => ({ registerIcon: jest.fn() }))
8
- jest.mock('./keyboardShortcutRegistry.js', () => ({ registerKeyboardShortcut: jest.fn() }))
9
8
 
10
9
  describe('pluginRegistry', () => {
11
10
  let pluginRegistry
@@ -18,9 +17,11 @@ describe('pluginRegistry', () => {
18
17
  registerPanel = require('./panelRegistry.js').registerPanel
19
18
  registerControl = require('./controlRegistry.js').registerControl
20
19
  registerIcon = require('./iconRegistry.js').registerIcon
21
- registerKeyboardShortcut = require('./keyboardShortcutRegistry.js').registerKeyboardShortcut
20
+ // registerKeyboardShortcut is injected (per-instance registry), not imported from a module —
21
+ // see keyboardShortcutRegistry.js's createKeyboardShortcutRegistry.
22
+ registerKeyboardShortcut = jest.fn()
22
23
 
23
- pluginRegistry = createPluginRegistry({ registerButton, registerPanel, registerControl })
24
+ pluginRegistry = createPluginRegistry({ registerButton, registerPanel, registerControl, registerKeyboardShortcut })
24
25
  })
25
26
 
26
27
  it('registers plugin and pushes to registeredPlugins', () => {
@@ -170,6 +171,18 @@ describe('pluginRegistry', () => {
170
171
  pluginRegistry.registerPlugin(plugin)
171
172
  expect(console.warn).not.toHaveBeenCalled()
172
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
+ })
173
186
  })
174
187
 
175
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 = {
@@ -49,8 +49,7 @@ function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) {
49
49
  })
50
50
  }
51
51
 
52
- function createButtonClickHandler (btn, appState, evaluateProp) {
53
- const [, config] = btn
52
+ function createButtonClickHandler (config, appState, evaluateProp) {
54
53
  const isPanelOpen = !!(config.panelId && appState.openPanels[config.panelId])
55
54
  const isToggle = config.isPressed !== undefined || !!config.pressedWhen
56
55
 
@@ -134,15 +133,33 @@ function applySlotExclusivity (matching, appState) {
134
133
  return matching.filter(([_, config]) => config.pluginId === exclusivePluginId)
135
134
  }
136
135
 
137
- function renderButton ({ btn, appState, appConfig, evaluateProp }) {
138
- const [buttonId, config] = btn
136
+ /**
137
+ * Builds the props for a <SlotButton>. isHidden/variant are included here (not just derived
138
+ * inside SlotButton) because Actions.jsx also reads them directly off the 'actions' slot's
139
+ * immediate children via React.Children.toArray — they need to be top-level props on whatever
140
+ * element ends up there, not just used internally to build MapButton.
141
+ */
142
+ const slotButtonProps = ({ buttonId, config, appState, appConfig, evaluateProp }) => ({
143
+ buttonId,
144
+ config,
145
+ appState,
146
+ appConfig,
147
+ evaluateProp,
148
+ isHidden: appState.hiddenButtons.has(buttonId),
149
+ variant: config.variant
150
+ })
151
+
152
+ /**
153
+ * Renders a single button's MapButton element for its slot, computing its click handler
154
+ * and derived state (panel-open, showLabel fallback, etc.) from its config and appState.
155
+ */
156
+ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) {
139
157
  const bpConfig = config[appState.breakpoint] ?? {}
140
- const handleClick = createButtonClickHandler(btn, appState, evaluateProp)
158
+ const handleClick = createButtonClickHandler(config, appState, evaluateProp)
141
159
  const isPanelOpen = !!(config.panelId && appState.openPanels[config.panelId])
142
160
 
143
161
  return (
144
162
  <MapButton
145
- key={buttonId}
146
163
  buttonId={buttonId}
147
164
  iconId={evaluateProp(config.iconId, config.pluginId)}
148
165
  iconSvgContent={evaluateProp(config.iconSvgContent, config.pluginId)}
@@ -219,21 +236,20 @@ function mapButtons ({ slot, appState, appConfig, evaluateProp }) {
219
236
  id: buttonId,
220
237
  type: 'button',
221
238
  order,
222
- element: renderButton({ btn, appState, appConfig, evaluateProp })
239
+ element: <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, appState, appConfig, evaluateProp })} />
223
240
  })
224
241
  }
225
242
 
226
243
  for (const [groupName, { label, order: groupOrder, members }] of groupMap) {
227
244
  if (members.length < 2) {
228
245
  // Singleton group: degrade to a regular button using the group's slot order
229
- const btn = members[0]
230
- const [buttonId, config] = btn
246
+ const [buttonId, config] = members[0]
231
247
  const order = groupOrder || config[breakpoint]?.order || 0
232
248
  result.push({
233
249
  id: buttonId,
234
250
  type: 'button',
235
251
  order,
236
- element: renderButton({ btn, appState, appConfig, evaluateProp })
252
+ element: <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, appState, appConfig, evaluateProp })} />
237
253
  })
238
254
  continue
239
255
  }
@@ -251,7 +267,7 @@ function mapButtons ({ slot, appState, appConfig, evaluateProp }) {
251
267
  order: groupOrder,
252
268
  element: (
253
269
  <div key={`group-${groupName}`} role='group' aria-label={label} className='im-c-button-group'>{/* NOSONAR - div with role="group" is correct for a button group */}
254
- {sorted.map(btn => renderButton({ btn, appState, appConfig, evaluateProp }))}
270
+ {sorted.map(([buttonId, config]) => <SlotButton key={buttonId} {...slotButtonProps({ buttonId, config, appState, appConfig, evaluateProp })} />)}
255
271
  </div>
256
272
  )
257
273
  })
@@ -264,7 +280,7 @@ export {
264
280
  mapButtons,
265
281
  getMatchingButtons,
266
282
  applySlotExclusivity,
267
- renderButton,
283
+ SlotButton,
268
284
  resolveGroupName,
269
285
  resolveGroupLabel,
270
286
  resolveGroupOrder
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { mapButtons, getMatchingButtons, applySlotExclusivity, renderButton, resolveGroupName, resolveGroupLabel, resolveGroupOrder } from './mapButtons.js'
2
+ import { mapButtons, getMatchingButtons, applySlotExclusivity, SlotButton, resolveGroupName, resolveGroupLabel, resolveGroupOrder } from './mapButtons.js'
3
3
  import { logger } from '../../services/logger.js'
4
4
  import { getPanelConfig } from '../registry/panelRegistry.js'
5
5
 
@@ -156,11 +156,11 @@ describe('mapButtons module', () => {
156
156
  })
157
157
 
158
158
  // -------------------------
159
- // renderButton tests
159
+ // SlotButton tests
160
160
  // -------------------------
161
- describe('renderButton', () => {
161
+ describe('SlotButton', () => {
162
162
  const render = (config, state = appState) =>
163
- renderButton({ btn: ['id', config], appState: state, appConfig, evaluateProp })
163
+ SlotButton({ buttonId: 'id', config, appState: state, appConfig, evaluateProp })
164
164
 
165
165
  it('renders a MapButton with correct basic props', () => {
166
166
  const result = render(baseBtn)
@@ -404,5 +404,18 @@ describe('mapButtons module', () => {
404
404
  expect(result).toHaveLength(1)
405
405
  expect(result[0].id).toBe('parent')
406
406
  })
407
+
408
+ // Actions.jsx (the 'actions' slot wrapper) reads isHidden/variant directly off each slot
409
+ // item's top-level element props via React.Children.toArray — these two guard that contract.
410
+ it('carries isHidden as a top-level prop on the slot element, not just inside MapButton', () => {
411
+ appState.buttonConfig = ({ b1: baseBtn })
412
+ appState.hiddenButtons = new Set(['b1'])
413
+ expect(map()[0].element.props.isHidden).toBe(true)
414
+ })
415
+
416
+ it('carries variant as a top-level prop on the slot element, not just inside MapButton', () => {
417
+ appState.buttonConfig = ({ b1: { ...baseBtn, variant: 'touch' } })
418
+ expect(map()[0].element.props.variant).toBe('touch')
419
+ })
407
420
  })
408
421
  })
@@ -28,7 +28,9 @@ export function mapControls ({ slot, appState, evaluateProp }) {
28
28
  return false
29
29
  }
30
30
 
31
- const slotAllowed = allowedSlots.control.includes(bpConfig.slot)
31
+ // A control may also target a panel's body directly via the `<panelId>-panel`
32
+ // slot convention (mirrors the `<buttonId>-button` convention panels already use).
33
+ const slotAllowed = allowedSlots.control.includes(bpConfig.slot) || bpConfig.slot?.endsWith('-panel')
32
34
  const inModeWhitelist = control.includeModes?.includes(mode) ?? true
33
35
  const inExcludeModes = control.excludeModes?.includes(mode) ?? false
34
36
 
@@ -164,4 +164,20 @@ describe('mapControls', () => {
164
164
  const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })
165
165
  expect(result.map(c => c.id)).toEqual(['ctrl1'])
166
166
  })
167
+
168
+ it('matches a control targeting a panel-body slot via the <panelId>-panel convention', () => {
169
+ defaultAppState.controlConfig = ({
170
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', order: 1 }, includeModes: ['view'] }
171
+ })
172
+ const result = mapControls({ slot: 'map-styles-panel', appState: defaultAppState, evaluateProp: (p) => p })
173
+ expect(result.map(c => c.id)).toEqual(['ctrl1'])
174
+ })
175
+
176
+ it('does not match a panel-body-targeting control against an unrelated slot', () => {
177
+ defaultAppState.controlConfig = ({
178
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', order: 1 }, includeModes: ['view'] }
179
+ })
180
+ const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })
181
+ expect(result).toEqual([])
182
+ })
167
183
  })