@defra/interactive-map 0.0.40-alpha → 0.0.42-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 (374) 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/button-definition.md +7 -8
  7. package/docs/api/control-definition.md +17 -1
  8. package/docs/api/panel-definition.md +5 -0
  9. package/docs/api/slots.md +50 -6
  10. package/docs/assets/images/slot-map.svg +61 -262
  11. package/docs/examples/add-polygons.mdx +8 -2
  12. package/docs/examples/add-symbols.mdx +8 -2
  13. package/docs/examples/draw-tools.mdx +3 -3
  14. package/docs/plugins/datasets.md +23 -15
  15. package/docs/plugins/draw.md +79 -16
  16. package/docs/plugins/map-key.md +147 -0
  17. package/docs/plugins/search.md +1 -1
  18. package/docs/plugins.md +5 -1
  19. package/govuk-prototype-kit.config.json +2 -0
  20. package/jest.setup.js +5 -0
  21. package/package.json +9 -1
  22. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  23. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  24. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  25. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  26. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  27. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  28. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  29. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  30. package/plugins/beta/use-location/src/manifest.js +1 -1
  31. package/plugins/datasets/dist/css/index.css +1 -1
  32. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  33. package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
  34. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  35. package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
  36. package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
  37. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  38. package/plugins/datasets/dist/umd/index.js +1 -1
  39. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js +175 -0
  40. package/plugins/datasets/src/adapters/esri/registry/esriDataset.test.js +55 -0
  41. package/plugins/datasets/src/adapters/loadLayerAdapter.test.js +61 -0
  42. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +14 -0
  43. package/plugins/datasets/src/api/setOpacity.test.js +9 -0
  44. package/plugins/datasets/src/components/LayersMenu/LayersMenu.jsx +10 -25
  45. package/plugins/datasets/src/components/LayersMenu/LayersMenu.test.jsx +0 -42
  46. package/plugins/datasets/src/datasets.scss +0 -1
  47. package/plugins/datasets/src/fetch/createDynamicSource.js +135 -129
  48. package/plugins/datasets/src/fetch/createDynamicSource.test.js +318 -0
  49. package/plugins/datasets/src/fetch/fetchGeoJSON.test.js +83 -0
  50. package/plugins/datasets/src/index.test.js +36 -0
  51. package/plugins/datasets/src/initialise/DatasetsInit.jsx +17 -7
  52. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +262 -0
  53. package/plugins/datasets/src/initialise/initialiseDatasets.js +16 -4
  54. package/plugins/datasets/src/initialise/initialiseDatasets.test.js +42 -0
  55. package/plugins/datasets/src/manifest.js +0 -38
  56. package/plugins/datasets/src/manifest.test.js +49 -0
  57. package/plugins/datasets/src/reducers/datasetsToMenu.test.js +54 -1
  58. package/plugins/datasets/src/reducers/pluginState.js +3 -16
  59. package/plugins/datasets/src/reducers/pluginState.test.js +23 -0
  60. package/plugins/datasets/src/registry/dataset.js +13 -1
  61. package/plugins/datasets/src/registry/dataset.test.js +61 -0
  62. package/plugins/datasets/src/registry/datasetRegistry.js +12 -6
  63. package/plugins/datasets/src/registry/datasetRegistry.test.js +16 -4
  64. package/plugins/datasets/src/registry/isVisibleWhen.js +6 -3
  65. package/plugins/datasets/src/registry/isVisibleWhen.test.js +11 -1
  66. package/plugins/datasets/src/utils/bbox.test.js +125 -0
  67. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  68. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  69. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  70. package/plugins/draw/dist/esm/index.js +1 -1
  71. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +2 -1
  72. package/plugins/draw/dist/umd/im-draw-ml-adapter.js.LICENSE.txt +1 -0
  73. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  74. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  75. package/plugins/draw/dist/umd/index.js +1 -1
  76. package/plugins/draw/src/DrawInit.jsx +10 -2
  77. package/plugins/draw/src/DrawInit.test.jsx +51 -10
  78. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +91 -11
  79. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +246 -4
  80. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +109 -14
  81. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +174 -11
  82. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +12 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +9 -1
  84. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +24 -3
  85. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +194 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +457 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/__helpers__/harness.js +102 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +82 -0
  89. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.test.js +83 -0
  90. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +55 -0
  91. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.test.js +62 -0
  92. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.js +138 -0
  93. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointerHandlers.test.js +197 -0
  94. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.js +92 -0
  95. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/touchHandlers.test.js +101 -0
  96. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.js +46 -0
  97. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/undoHandlers.test.js +42 -0
  98. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +151 -0
  99. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +156 -0
  100. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +4 -27
  101. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +24 -39
  102. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +4 -5
  103. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +20 -23
  104. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +25 -5
  105. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +6 -36
  106. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -49
  107. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +3 -53
  108. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +8 -33
  109. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +29 -72
  110. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +10 -3
  111. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.js +158 -0
  112. package/plugins/draw/src/adapters/maplibre/pointSymbolImages.test.js +314 -0
  113. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +6 -1
  114. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +21 -0
  115. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +6 -2
  116. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +12 -0
  117. package/plugins/draw/src/adapters/maplibre/styles.js +38 -11
  118. package/plugins/draw/src/adapters/maplibre/styles.test.js +39 -0
  119. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.js +79 -0
  120. package/plugins/draw/src/adapters/maplibre/utils/editModeEvents.test.js +135 -0
  121. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.js +42 -0
  122. package/plugins/draw/src/adapters/maplibre/utils/geometryValidation.test.js +85 -0
  123. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.js +34 -0
  124. package/plugins/draw/src/adapters/maplibre/utils/keyboardShortcuts.test.js +78 -0
  125. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +16 -3
  126. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +21 -3
  127. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +59 -0
  128. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.test.js +73 -0
  129. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.js +33 -0
  130. package/plugins/draw/src/adapters/maplibre/utils/touchDragMath.test.js +70 -0
  131. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +27 -2
  132. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +93 -0
  133. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +9 -0
  134. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +19 -1
  135. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +34 -1
  136. package/plugins/draw/src/adapters/openlayers/core/styles.js +73 -1
  137. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +122 -3
  138. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +12 -0
  139. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +8 -2
  140. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +31 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +9 -5
  142. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +33 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +5 -3
  144. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +11 -0
  145. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +10 -3
  146. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +24 -1
  147. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +18 -13
  148. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +46 -1
  149. package/plugins/draw/src/adapters/openlayers/olDraw.js +11 -4
  150. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +48 -6
  151. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +156 -0
  152. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +246 -0
  153. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +267 -0
  154. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +327 -0
  155. package/plugins/draw/src/adapters/openlayers/point/pointDragInteraction.js +71 -0
  156. package/plugins/draw/src/adapters/openlayers/point/pointOps.js +34 -0
  157. package/plugins/draw/src/adapters/openlayers/point/pointOps.test.js +32 -0
  158. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.js +82 -0
  159. package/plugins/draw/src/adapters/openlayers/point/pointSelectionState.test.js +107 -0
  160. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.js +83 -0
  161. package/plugins/draw/src/adapters/openlayers/point/pointSymbolImages.test.js +214 -0
  162. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +3 -1
  163. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +6 -9
  164. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +15 -4
  165. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +1 -1
  166. package/plugins/draw/src/api/addFeature.js +16 -1
  167. package/plugins/draw/src/api/addFeature.test.js +62 -5
  168. package/plugins/draw/src/api/createNewShape.js +41 -0
  169. package/plugins/draw/src/api/createNewShape.test.js +63 -0
  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/newLine.js +2 -40
  173. package/plugins/draw/src/api/newPoint.js +42 -0
  174. package/plugins/draw/src/api/newPoint.test.js +117 -0
  175. package/plugins/draw/src/api/newPolygon.js +2 -40
  176. package/plugins/draw/src/api/setStyle.js +43 -0
  177. package/plugins/draw/src/api/setStyle.test.js +75 -0
  178. package/plugins/draw/src/events.js +17 -10
  179. package/plugins/draw/src/events.test.js +14 -0
  180. package/plugins/draw/src/manifest.js +18 -6
  181. package/plugins/draw/src/manifest.test.js +32 -1
  182. package/plugins/draw/src/reducer.test.js +7 -0
  183. package/plugins/draw/src/utils/stripInternalProperties.js +19 -0
  184. package/plugins/draw/src/utils/stripInternalProperties.test.js +50 -0
  185. package/plugins/draw/src/utils/symbolKeys.js +5 -0
  186. package/plugins/draw/src/validation/liveDrawChecks.test.js +54 -0
  187. package/plugins/draw/src/validation/liveStroke.test.js +64 -1
  188. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  189. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  190. package/plugins/interact/dist/umd/index.js +1 -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/manifest.js +1 -1
  194. package/plugins/interact/src/utils/featureQueries.js +18 -0
  195. package/plugins/interact/src/utils/featureQueries.test.js +15 -0
  196. package/plugins/interact/src/utils/spatial.js +9 -3
  197. package/plugins/interact/src/utils/spatial.test.js +12 -1
  198. package/plugins/map-key/dist/css/index.css +68 -0
  199. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -0
  200. package/plugins/map-key/dist/esm/index.js +1 -0
  201. package/plugins/map-key/dist/umd/im-map-key-plugin.js +2 -0
  202. package/plugins/map-key/dist/umd/im-map-key-plugin.js.LICENSE.txt +1 -0
  203. package/plugins/map-key/dist/umd/index.js +2 -0
  204. package/plugins/map-key/dist/umd/index.js.LICENSE.txt +1 -0
  205. package/plugins/map-key/package.json +28 -0
  206. package/plugins/map-key/src/components/Key/EmptyKey.jsx +7 -0
  207. package/plugins/{datasets → map-key}/src/components/Key/EmptyKey.test.jsx +2 -2
  208. package/plugins/map-key/src/components/Key/Key.jsx +43 -0
  209. package/plugins/{datasets → map-key}/src/components/Key/Key.module.scss +21 -14
  210. package/plugins/map-key/src/components/Key/Key.test.jsx +73 -0
  211. package/plugins/map-key/src/components/Key/KeyGroupItem.jsx +16 -0
  212. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +53 -0
  213. package/plugins/map-key/src/components/Key/KeyItem.jsx +26 -0
  214. package/plugins/map-key/src/components/Key/KeyItem.test.jsx +67 -0
  215. package/plugins/map-key/src/components/Key/KeySvg.jsx +41 -0
  216. package/plugins/{datasets → map-key}/src/components/Key/KeySvg.test.jsx +14 -12
  217. package/plugins/map-key/src/components/Key/KeySvgLine.jsx +22 -0
  218. package/plugins/{datasets → map-key}/src/components/Key/KeySvgLine.test.jsx +1 -1
  219. package/plugins/{datasets → map-key}/src/components/Key/KeySvgPattern.jsx +7 -2
  220. package/plugins/{datasets → map-key}/src/components/Key/KeySvgPattern.test.jsx +8 -2
  221. package/plugins/map-key/src/components/Key/KeySvgRect.jsx +26 -0
  222. package/plugins/{datasets → map-key}/src/components/Key/KeySvgRect.test.jsx +1 -1
  223. package/plugins/{datasets → map-key}/src/components/Key/KeySvgSymbol.jsx +8 -3
  224. package/plugins/{datasets → map-key}/src/components/Key/KeySvgSymbol.test.jsx +17 -6
  225. package/plugins/map-key/src/components/Key/MapKey.jsx +53 -0
  226. package/plugins/map-key/src/components/Key/MapKey.test.jsx +147 -0
  227. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.js +2 -2
  228. package/plugins/{datasets → map-key}/src/components/Key/svgProperties.test.js +2 -2
  229. package/plugins/map-key/src/index.js +13 -0
  230. package/plugins/map-key/src/index.test.js +31 -0
  231. package/plugins/map-key/src/initialise/MapKeyInit.jsx +15 -0
  232. package/plugins/map-key/src/initialise/MapKeyInit.test.jsx +39 -0
  233. package/plugins/map-key/src/manifest.js +30 -0
  234. package/plugins/map-key/src/manifest.test.js +63 -0
  235. package/plugins/map-key/src/mapKey.scss +4 -0
  236. package/plugins/map-key/src/registry/getDatasetRegistry.js +6 -0
  237. package/plugins/map-key/src/registry/getDatasetRegistry.test.js +23 -0
  238. package/plugins/map-key/src/registry/index.js +3 -0
  239. package/plugins/map-key/src/registry/index.test.js +15 -0
  240. package/plugins/menu/dist/css/index.css +80 -0
  241. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -0
  242. package/plugins/menu/dist/esm/index.js +1 -0
  243. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -0
  244. package/plugins/menu/dist/umd/index.js +2 -0
  245. package/plugins/menu/dist/umd/index.js.LICENSE.txt +1 -0
  246. package/plugins/menu/package.json +28 -0
  247. package/plugins/menu/src/components/Menu/CheckboxGroupWrapper.jsx +21 -0
  248. package/plugins/menu/src/components/Menu/GroupLegend.jsx +17 -0
  249. package/plugins/menu/src/components/Menu/GroupLegend.test.jsx +68 -0
  250. package/plugins/menu/src/components/Menu/Menu.jsx +19 -0
  251. package/plugins/menu/src/components/Menu/Menu.module.scss +90 -0
  252. package/plugins/menu/src/components/Menu/Menu.test.jsx +161 -0
  253. package/plugins/menu/src/components/Menu/MenuCheckbox.jsx +35 -0
  254. package/plugins/menu/src/components/Menu/MenuCheckbox.test.jsx +93 -0
  255. package/plugins/menu/src/components/Menu/MenuGroup.jsx +11 -0
  256. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.jsx → menu/src/components/Menu/MenuRadio.jsx} +6 -4
  257. package/plugins/{datasets/src/components/LayersMenu/LayersMenuRadio.test.jsx → menu/src/components/Menu/MenuRadio.test.jsx} +18 -18
  258. package/plugins/menu/src/components/Menu/RadioGroupWrapper.jsx +32 -0
  259. package/plugins/{datasets/src/components/LayersMenu/LayersRadioGroupWrapper.test.jsx → menu/src/components/Menu/RadioGroupWrapper.test.jsx} +21 -21
  260. package/plugins/menu/src/components/Menu/useIdPrefix.jsx +4 -0
  261. package/plugins/menu/src/index.js +13 -0
  262. package/plugins/menu/src/index.test.js +31 -0
  263. package/plugins/menu/src/initialise/MenuInit.jsx +24 -0
  264. package/plugins/menu/src/initialise/MenuInit.test.jsx +50 -0
  265. package/plugins/menu/src/initialise/createMenu.js +10 -0
  266. package/plugins/menu/src/initialise/createMenu.test.js +37 -0
  267. package/plugins/menu/src/manifest.js +35 -0
  268. package/plugins/menu/src/manifest.test.js +63 -0
  269. package/plugins/menu/src/menu.scss +4 -0
  270. package/plugins/menu/src/reducers/menuStateReducer.test.js +38 -0
  271. package/plugins/menu/src/reducers/pluginState.js +35 -0
  272. package/plugins/menu/src/reducers/pluginState.test.js +57 -0
  273. package/plugins/menu/src/registry/getDatasetRegistry.js +9 -0
  274. package/plugins/menu/src/registry/getDatasetRegistry.test.js +23 -0
  275. package/plugins/menu/src/registry/index.js +3 -0
  276. package/plugins/menu/src/registry/index.test.js +15 -0
  277. package/plugins/menu/src/registry/isVisibleWhen.js +43 -0
  278. package/plugins/menu/src/registry/isVisibleWhen.test.js +48 -0
  279. package/plugins/search/dist/css/index.css +1 -1
  280. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  281. package/plugins/search/dist/esm/index.js +1 -1
  282. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  283. package/plugins/search/dist/umd/index.js +1 -1
  284. package/plugins/search/src/Search.jsx +2 -3
  285. package/plugins/search/src/components/Form/Form.module.scss +9 -20
  286. package/plugins/search/src/index.js +0 -1
  287. package/plugins/search/src/index.test.js +2 -4
  288. package/plugins/search/src/manifest.js +1 -1
  289. package/plugins/search/src/search.scss +17 -16
  290. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  291. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  292. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  293. package/providers/beta/openlayers/src/openlayersProvider.js +4 -1
  294. package/providers/beta/openlayers/src/openlayersProvider.test.js +18 -0
  295. package/providers/beta/openlayers/src/utils/highlightFeatures.js +52 -5
  296. package/providers/beta/openlayers/src/utils/highlightFeatures.test.js +176 -0
  297. package/providers/beta/openlayers/src/utils/symbolImages.js +40 -0
  298. package/providers/beta/openlayers/src/utils/symbolImages.test.js +58 -0
  299. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  300. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  301. package/providers/maplibre/dist/umd/index.js +1 -1
  302. package/providers/maplibre/src/utils/drawLayerBuckets.js +14 -0
  303. package/providers/maplibre/src/utils/drawLayerBuckets.test.js +18 -0
  304. package/providers/maplibre/src/utils/highlightFeatures.js +80 -55
  305. package/providers/maplibre/src/utils/highlightFeatures.test.js +156 -17
  306. package/providers/maplibre/src/utils/hoverCursor.js +30 -14
  307. package/providers/maplibre/src/utils/hoverCursor.test.js +35 -0
  308. package/providers/maplibre/src/utils/patternImages.js +1 -1
  309. package/providers/maplibre/src/utils/symbolImages.js +41 -3
  310. package/providers/maplibre/src/utils/symbolImages.test.js +40 -6
  311. package/rollup.esm.mjs +11 -1
  312. package/src/App/components/Actions/Actions.test.jsx +47 -0
  313. package/src/App/components/Hints/Hints.test.jsx +13 -0
  314. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +25 -23
  315. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +22 -5
  316. package/src/App/components/MoveControls/MoveControls.module.scss +41 -10
  317. package/src/App/components/Panel/Panel.jsx +65 -8
  318. package/src/App/components/Panel/Panel.module.scss +49 -15
  319. package/src/App/components/Panel/Panel.test.jsx +114 -1
  320. package/src/App/components/Tabs/Tabs.jsx +43 -17
  321. package/src/App/components/Tabs/Tabs.module.scss +32 -5
  322. package/src/App/components/Tabs/Tabs.test.jsx +40 -10
  323. package/src/App/components/Tooltip/Tooltip.test.jsx +12 -14
  324. package/src/App/hooks/useFeatureItems.js +16 -7
  325. package/src/App/hooks/useFeatureItems.test.js +57 -0
  326. package/src/App/hooks/useLayoutMeasurements.js +95 -84
  327. package/src/App/hooks/useLayoutMeasurements.test.js +61 -1
  328. package/src/App/layout/Layout.jsx +9 -10
  329. package/src/App/layout/Layout.test.jsx +2 -0
  330. package/src/App/layout/layout.module.scss +31 -17
  331. package/src/App/registry/pluginRegistry.js +1 -1
  332. package/src/App/registry/pluginRegistry.test.js +12 -0
  333. package/src/App/renderer/HtmlElementHost.jsx +18 -5
  334. package/src/App/renderer/HtmlElementHost.test.jsx +54 -0
  335. package/src/App/renderer/groupByKey.js +28 -0
  336. package/src/App/renderer/groupByKey.test.js +39 -0
  337. package/src/App/renderer/groupIntoTabs.js +42 -0
  338. package/src/App/renderer/groupIntoTabs.test.js +95 -0
  339. package/src/App/renderer/mapButtons.js +97 -129
  340. package/src/App/renderer/mapButtons.test.js +67 -83
  341. package/src/App/renderer/mapControls.js +4 -1
  342. package/src/App/renderer/mapControls.test.js +32 -0
  343. package/src/App/renderer/mapPanels.js +53 -11
  344. package/src/App/renderer/mapPanels.test.js +95 -2
  345. package/src/App/renderer/orderItems.js +28 -0
  346. package/src/App/renderer/orderItems.test.js +50 -0
  347. package/src/App/renderer/slotAggregator.js +2 -18
  348. package/src/App/renderer/slotHelpers.js +3 -1
  349. package/src/App/renderer/slotHelpers.test.js +5 -0
  350. package/src/App/renderer/slots.js +3 -0
  351. package/src/App/store/AppProvider.jsx +1 -0
  352. package/src/App/store/appDispatchMiddleware.js +1 -1
  353. package/src/App/store/appDispatchMiddleware.test.js +8 -0
  354. package/src/config/appConfig.js +2 -2
  355. package/src/config/events.js +2 -0
  356. package/src/scss/settings/_colors.scss +1 -0
  357. package/src/scss/settings/_dimensions.scss +2 -3
  358. package/src/scss/settings/_transition.scss +1 -1
  359. package/src/services/eventBus.js +44 -0
  360. package/src/services/eventBus.test.js +83 -0
  361. package/src/services/symbolRegistry.js +1 -1
  362. package/src/types.js +31 -3
  363. package/webpack.umd.mjs +2 -0
  364. package/plugins/datasets/src/components/Key/EmptyKey.jsx +0 -7
  365. package/plugins/datasets/src/components/Key/Key.jsx +0 -50
  366. package/plugins/datasets/src/components/Key/KeyGroupItem.jsx +0 -18
  367. package/plugins/datasets/src/components/Key/KeyItem.jsx +0 -23
  368. package/plugins/datasets/src/components/Key/KeySvg.jsx +0 -22
  369. package/plugins/datasets/src/components/Key/KeySvgLine.jsx +0 -19
  370. package/plugins/datasets/src/components/Key/KeySvgRect.jsx +0 -22
  371. package/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx +0 -41
  372. /package/plugins/{datasets → menu}/src/reducers/menuStateReducer.js +0 -0
  373. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.js +0 -0
  374. /package/{providers/maplibre/src → src}/utils/rasteriseToImageData.test.js +0 -0
@@ -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 } from './mapButtons.js'
3
3
  import { logger } from '../../services/logger.js'
4
4
  import { getPanelConfig } from '../registry/panelRegistry.js'
5
5
 
@@ -47,52 +47,6 @@ describe('mapButtons module', () => {
47
47
  getPanelConfig.mockReturnValue({})
48
48
  })
49
49
 
50
- // -------------------------
51
- // resolveGroup* helper tests
52
- // -------------------------
53
- describe('resolveGroupName', () => {
54
- it('returns null when group is null or undefined', () => {
55
- expect(resolveGroupName(null)).toBeNull()
56
- expect(resolveGroupName(undefined)).toBeNull()
57
- })
58
- it('returns the string when group is a string', () => {
59
- expect(resolveGroupName('g1')).toBe('g1')
60
- })
61
- it('returns group.name when group is an object', () => {
62
- expect(resolveGroupName({ name: 'g1' })).toBe('g1')
63
- expect(resolveGroupName({ name: undefined })).toBeNull()
64
- })
65
- })
66
-
67
- describe('resolveGroupLabel', () => {
68
- it('returns empty string when group is falsy', () => {
69
- expect(resolveGroupLabel(null)).toBe('')
70
- expect(resolveGroupLabel(undefined)).toBe('')
71
- })
72
- it('returns the string itself when group is a string', () => {
73
- expect(resolveGroupLabel('My Group')).toBe('My Group')
74
- })
75
- it('returns group.label when provided, else group.name, else empty string', () => {
76
- expect(resolveGroupLabel({ name: 'g1', label: 'Group One' })).toBe('Group One')
77
- expect(resolveGroupLabel({ name: 'g1' })).toBe('g1')
78
- expect(resolveGroupLabel({ order: 5 })).toBe('')
79
- })
80
- })
81
-
82
- describe('resolveGroupOrder', () => {
83
- it('returns 0 when group is falsy', () => {
84
- expect(resolveGroupOrder(null)).toBe(0)
85
- expect(resolveGroupOrder(undefined)).toBe(0)
86
- })
87
- it('returns 0 when group is a string', () => {
88
- expect(resolveGroupOrder('g1')).toBe(0)
89
- })
90
- it('returns group.order when provided, else 0', () => {
91
- expect(resolveGroupOrder({ name: 'g1', order: 5 })).toBe(5)
92
- expect(resolveGroupOrder({ name: 'g1' })).toBe(0)
93
- })
94
- })
95
-
96
50
  // -------------------------
97
51
  // getMatchingButtons tests
98
52
  // -------------------------
@@ -156,11 +110,11 @@ describe('mapButtons module', () => {
156
110
  })
157
111
 
158
112
  // -------------------------
159
- // renderButton tests
113
+ // SlotButton tests
160
114
  // -------------------------
161
- describe('renderButton', () => {
115
+ describe('SlotButton', () => {
162
116
  const render = (config, state = appState) =>
163
- renderButton({ btn: ['id', config], appState: state, appConfig, evaluateProp })
117
+ SlotButton({ buttonId: 'id', config, appState: state, appConfig, evaluateProp })
164
118
 
165
119
  it('renders a MapButton with correct basic props', () => {
166
120
  const result = render(baseBtn)
@@ -283,68 +237,85 @@ describe('mapButtons module', () => {
283
237
  expect(result[0]).toMatchObject({ id: 'b1', type: 'button', order: 1 })
284
238
  })
285
239
 
286
- it('renders grouped buttons as a single group item with role=group', () => {
240
+ it('renders grouped buttons as a single group item with role=group, keyed by kebab-cased label', () => {
287
241
  appState.buttonConfig = ({
288
- b1: { ...baseBtn, group: { name: 'g1', label: 'Group 1', order: 2 } },
289
- b2: { ...baseBtn, desktop: { slot: 'header', order: 2 }, group: { name: 'g1', label: 'Group 1', order: 2 } }
242
+ b1: { ...baseBtn, group: { label: 'Group 1', slotOrder: 2 } },
243
+ b2: { ...baseBtn, desktop: { slot: 'header', order: 2 }, group: { label: 'Group 1', slotOrder: 2 } }
290
244
  })
291
245
  const result = map()
292
246
  expect(result).toHaveLength(1)
293
- expect(result[0]).toMatchObject({ id: 'group-g1', type: 'group', order: 2 })
247
+ // stringToKebab only hyphenates camelCase boundaries, not spaces — this id is an internal
248
+ // React key/identifier only, never rendered as a literal DOM id, so that's harmless here.
249
+ expect(result[0]).toMatchObject({ id: 'group-group 1', type: 'group', order: 2 })
294
250
  expect(result[0].element.props.role).toBe('group')
295
251
  expect(result[0].element.props['aria-label']).toBe('Group 1')
296
252
  })
297
253
 
298
- it('uses group name as aria-label when no explicit label is provided', () => {
254
+ it('merges group labels that differ only by case/whitespace into one group', () => {
299
255
  appState.buttonConfig = ({
300
- b1: { ...baseBtn, group: { name: 'g1', order: 0 } },
301
- b2: { ...baseBtn, group: { name: 'g1', order: 0 } }
256
+ b1: { ...baseBtn, group: { label: 'Zoom Controls' } },
257
+ b2: { ...baseBtn, group: { label: 'zoom controls' } }
302
258
  })
303
259
  const result = map()
304
- expect(result[0].element.props['aria-label']).toBe('g1')
260
+ expect(result).toHaveLength(1)
261
+ // First-encountered member's raw label wins, same convention as panel tabs (groupIntoTabs.js)
262
+ expect(result[0].element.props['aria-label']).toBe('Zoom Controls')
305
263
  })
306
264
 
307
- it('sorts group members by intra-group order', () => {
265
+ it('orders group members via orderItems — unordered members keep natural sequence, an ordered one splices in by position', () => {
308
266
  appState.buttonConfig = ({
309
- b1: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 3 } },
310
- b2: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 1 } },
311
- b3: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 2 } }
267
+ b1: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header' } },
268
+ b2: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header', order: 1 } },
269
+ b3: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header' } }
312
270
  })
313
- const result = map()
314
- expect(result).toHaveLength(1)
315
- const children = result[0].element.props.children
316
- expect(children[0].props.buttonId).toBe('b2')
317
- expect(children[1].props.buttonId).toBe('b3')
318
- expect(children[2].props.buttonId).toBe('b1')
271
+ const children = map()[0].element.props.children
272
+ expect(children.map(c => c.props.buttonId)).toEqual(['b2', 'b1', 'b3'])
319
273
  })
320
274
 
321
- it('renders singleton groups as regular buttons using group slot order', () => {
322
- appState.buttonConfig = ({ b1: { ...baseBtn, group: { name: 'g1', label: 'Group 1', order: 3 } } })
275
+ it('renders singleton groups as regular buttons using the group\'s slot order', () => {
276
+ appState.buttonConfig = ({ b1: { ...baseBtn, group: { label: 'Group 1', slotOrder: 3 } } })
323
277
  const result = map()
324
278
  expect(result).toHaveLength(1)
325
279
  expect(result[0]).toMatchObject({ id: 'b1', type: 'button', order: 3 })
326
280
  })
327
281
 
328
- it('falls back to breakpoint order for singleton group when group order is 0', () => {
329
- appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header', order: 4 }, group: { name: 'g1', order: 0 } } })
330
- expect(map()[0].order).toBe(4)
282
+ it('does not fall back to the button\'s own breakpoint order when group slotOrder is explicitly 0', () => {
283
+ appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header', order: 4 }, group: { label: 'g1', slotOrder: 0 } } })
284
+ expect(map()[0].order).toBe(0)
331
285
  })
332
286
 
333
- it('falls back to 0 for singleton group when both group order and breakpoint order are absent', () => {
334
- appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header' }, group: { name: 'g1', order: 0 } } })
287
+ it('falls back to 0 for singleton group when group slotOrder is absent', () => {
288
+ appState.buttonConfig = ({ b1: { ...baseBtn, desktop: { slot: 'header' }, group: { label: 'g1' } } })
335
289
  expect(map()[0].order).toBe(0)
336
290
  })
337
291
 
338
- it('sorts group members treating missing breakpoint order as 0', () => {
292
+ it('warns in dev mode when group members declare inconsistent slotOrder values, using the first one encountered', () => {
339
293
  appState.buttonConfig = ({
340
- b1: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 2 } },
341
- b2: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header' } },
342
- b3: { ...baseBtn, group: { name: 'g1', order: 0 }, desktop: { slot: 'header', order: 1 } }
294
+ b1: { ...baseBtn, group: { label: 'g1', slotOrder: 2 } },
295
+ b2: { ...baseBtn, desktop: { slot: 'header', order: 1 }, group: { label: 'g1', slotOrder: 5 } }
343
296
  })
344
- const children = map()[0].element.props.children
345
- expect(children[0].props.buttonId).toBe('b2')
346
- expect(children[1].props.buttonId).toBe('b3')
347
- expect(children[2].props.buttonId).toBe('b1')
297
+ const result = map()
298
+ expect(result[0].order).toBe(2)
299
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('inconsistent slotOrder values'))
300
+ })
301
+
302
+ it('does not warn when group members agree on slotOrder', () => {
303
+ appState.buttonConfig = ({
304
+ b1: { ...baseBtn, group: { label: 'g1', slotOrder: 2 } },
305
+ b2: { ...baseBtn, desktop: { slot: 'header', order: 1 }, group: { label: 'g1', slotOrder: 2 } }
306
+ })
307
+ map()
308
+ expect(logger.warn).not.toHaveBeenCalled()
309
+ })
310
+
311
+ it('mixes grouped and ungrouped buttons in the same slot', () => {
312
+ appState.buttonConfig = ({
313
+ solo: { ...baseBtn, desktop: { slot: 'header', order: 1 } },
314
+ b1: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header' } },
315
+ b2: { ...baseBtn, group: { label: 'g1' }, desktop: { slot: 'header' } }
316
+ })
317
+ const result = map()
318
+ expect(result.map(r => r.id).sort()).toEqual(['group-g1', 'solo'])
348
319
  })
349
320
 
350
321
  it('falls back to order 0 when order is not specified in breakpoint config', () => {
@@ -404,5 +375,18 @@ describe('mapButtons module', () => {
404
375
  expect(result).toHaveLength(1)
405
376
  expect(result[0].id).toBe('parent')
406
377
  })
378
+
379
+ // Actions.jsx (the 'actions' slot wrapper) reads isHidden/variant directly off each slot
380
+ // item's top-level element props via React.Children.toArray — these two guard that contract.
381
+ it('carries isHidden as a top-level prop on the slot element, not just inside MapButton', () => {
382
+ appState.buttonConfig = ({ b1: baseBtn })
383
+ appState.hiddenButtons = new Set(['b1'])
384
+ expect(map()[0].element.props.isHidden).toBe(true)
385
+ })
386
+
387
+ it('carries variant as a top-level prop on the slot element, not just inside MapButton', () => {
388
+ appState.buttonConfig = ({ b1: { ...baseBtn, variant: 'touch' } })
389
+ expect(map()[0].element.props.variant).toBe('touch')
390
+ })
407
391
  })
408
392
  })
@@ -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
 
@@ -72,6 +74,7 @@ export function mapControls ({ slot, appState, evaluateProp }) {
72
74
  id: control.id,
73
75
  type: 'control',
74
76
  order: control[breakpoint]?.order ?? 0,
77
+ tab: control[breakpoint]?.tab,
75
78
  element
76
79
  }
77
80
  })
@@ -164,4 +164,36 @@ 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
+ })
183
+
184
+ it('passes through the tab field from the breakpoint config', () => {
185
+ defaultAppState.controlConfig = ({
186
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'map-styles-panel', tab: 'Styles' }, includeModes: ['view'] }
187
+ })
188
+ const result = mapControls({ slot: 'map-styles-panel', appState: defaultAppState, evaluateProp: (p) => p })
189
+ expect(result[0].tab).toBe('Styles')
190
+ })
191
+
192
+ it('leaves tab undefined when not set on the breakpoint config', () => {
193
+ defaultAppState.controlConfig = ({
194
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, includeModes: ['view'] }
195
+ })
196
+ const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })
197
+ expect(result[0].tab).toBeUndefined()
198
+ })
167
199
  })
@@ -4,6 +4,11 @@ import { withPluginContexts } from './pluginWrapper.js'
4
4
  import { Panel } from '../components/Panel/Panel.jsx'
5
5
  import { allowedSlots } from './slots.js'
6
6
  import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.js'
7
+ import { mapControls } from './mapControls.js'
8
+ import { orderItems } from './orderItems.js'
9
+ import { groupIntoTabs } from './groupIntoTabs.js'
10
+ import { stringToKebab } from '../../utils/stringToKebab.js'
11
+ import { logger } from '../../services/logger.js'
7
12
 
8
13
  /**
9
14
  * Determines whether a panel should be rendered in the given slot.
@@ -44,7 +49,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
44
49
  const cfg = panelConfig[panelId]?.[breakpoint]
45
50
  return cfg?.modal
46
51
  })
47
- const allowedModalPanelId = modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null
52
+ const allowedModalPanelId = modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null // NOSONAR, .at() is only Chrome 90+
48
53
 
49
54
  return openPanelEntries.map(([panelId, { props, focusOnOpen }]) => {
50
55
  const config = panelConfig[panelId]
@@ -73,13 +78,8 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
73
78
  const plugin = pluginRegistry.registeredPlugins.find(p => p.id === config.pluginId)
74
79
  const pluginId = plugin?.id
75
80
 
76
- const WrappedChild = config.render
77
- ? withPluginContexts(config.render, {
78
- ...props,
79
- pluginId,
80
- pluginConfig: plugin?.config
81
- })
82
- : null
81
+ const html = pluginId ? evaluateProp(config.html, pluginId) : config.html
82
+ const label = evaluateProp(config.label, pluginId)
83
83
 
84
84
  return {
85
85
  id: panelId,
@@ -92,12 +92,54 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
92
92
  panelConfig={config}
93
93
  props={props}
94
94
  focusOnOpen={focusOnOpen}
95
- WrappedChild={WrappedChild}
96
- label={evaluateProp(config.label, pluginId)}
97
- html={pluginId ? evaluateProp(config.html, pluginId) : config.html}
95
+ {...buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp })}
96
+ label={label}
97
+ html={html}
98
98
  />
99
99
  )
100
100
  }
101
101
  })
102
102
  .filter(Boolean)
103
103
  }
104
+
105
+ /**
106
+ * Builds a panel's body: its own render content (if any) plus any controls registered against
107
+ * its `<panelId>-panel` slot by other plugins, merged with `orderItems` — or, when two or more
108
+ * distinct `tab`s are present among them, grouped into tabs instead (see `groupIntoTabs`).
109
+ * Static-html panels don't build a body — dangerouslySetInnerHTML can't host injected content,
110
+ * so any controls targeting one are silently skipped (with a dev warning).
111
+ *
112
+ * @returns {{ items?: object[], tabs?: object[] }} spread directly onto `<Panel>` — exactly one
113
+ * of `items`/`tabs` is set (or neither, for a static-html panel).
114
+ */
115
+ function buildPanelBody ({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp }) {
116
+ const injectedItems = mapControls({
117
+ slot: `${stringToKebab(panelId)}-panel`,
118
+ appState,
119
+ evaluateProp
120
+ })
121
+
122
+ if (html) {
123
+ /* istanbul ignore next */
124
+ if (process.env.NODE_ENV !== 'production' && injectedItems.length > 0) {
125
+ logger.warn(`Panel "${panelId}" uses static html — controls targeting its slot are not rendered.`)
126
+ }
127
+ return {}
128
+ }
129
+
130
+ let ownItem = null
131
+ if (config.render) {
132
+ const WrappedChild = withPluginContexts(config.render, {
133
+ ...props,
134
+ pluginId,
135
+ pluginConfig: plugin?.config
136
+ })
137
+ ownItem = { id: panelId, order: 0, tab: bpConfig.tab, element: <WrappedChild {...props} /> }
138
+ }
139
+
140
+ const allItems = ownItem ? [ownItem, ...injectedItems] : injectedItems
141
+
142
+ const tabs = groupIntoTabs({ items: allItems, fallbackLabel: label })
143
+
144
+ return tabs ? { tabs } : { items: orderItems(allItems) }
145
+ }
@@ -2,10 +2,14 @@ import React from 'react'
2
2
  import { mapPanels } from './mapPanels.js'
3
3
  import { registeredPlugins } from '../registry/pluginRegistry.js'
4
4
  import { withPluginContexts } from './pluginWrapper.js'
5
+ import { mapControls } from './mapControls.js'
6
+ import { logger } from '../../services/logger.js'
5
7
 
8
+ jest.mock('../../services/logger.js')
6
9
  jest.mock('../registry/panelRegistry.js')
7
10
  jest.mock('../registry/pluginRegistry.js', () => ({ registeredPlugins: [] }))
8
11
  jest.mock('./pluginWrapper.js', () => ({ withPluginContexts: jest.fn((c) => c) }))
12
+ jest.mock('./mapControls.js', () => ({ mapControls: jest.fn(() => []) }))
9
13
  jest.mock('../components/Panel/Panel.jsx', () => ({ Panel: (props) => <div data-testid='panel' {...props} /> }))
10
14
  jest.mock('./slots.js', () => ({ allowedSlots: { panel: ['header', 'modal', 'left-top'] } }))
11
15
 
@@ -23,6 +27,7 @@ describe('mapPanels', () => {
23
27
 
24
28
  beforeEach(() => {
25
29
  jest.clearAllMocks()
30
+ mapControls.mockReturnValue([])
26
31
  registeredPlugins.length = 0
27
32
  defaultAppState = {
28
33
  breakpoint: 'desktop',
@@ -116,9 +121,97 @@ describe('mapPanels', () => {
116
121
  )
117
122
  })
118
123
 
119
- it('sets WrappedChild null if no render function', () => {
124
+ it('builds an empty items list if there is no render function and no injected controls', () => {
120
125
  const result = map()
121
- expect(result[0].element.props.WrappedChild).toBeNull()
126
+ expect(result[0].element.props.items).toEqual([])
127
+ })
128
+
129
+ it('builds a single-item items list for a panel with its own render content', () => {
130
+ const renderFn = () => <div>child</div>
131
+ defaultAppState.panelConfig = ({ p1: { ...baseConfig, render: renderFn } })
132
+ const result = map()
133
+ expect(result[0].element.props.items).toEqual([
134
+ { id: 'p1', order: 0, element: expect.anything() }
135
+ ])
136
+ })
137
+
138
+ it('requests injected controls using the <panelId>-panel slot convention', () => {
139
+ map()
140
+ expect(mapControls).toHaveBeenCalledWith(expect.objectContaining({ slot: 'p1-panel' }))
141
+ })
142
+
143
+ it('merges and orders the panel\'s own content with controls injected via mapControls', () => {
144
+ const renderFn = () => <div>child</div>
145
+ mapControls.mockReturnValue([{ id: 'injected1', order: 1, element: <span>injected</span> }])
146
+ defaultAppState.panelConfig = ({ p1: { ...baseConfig, render: renderFn } })
147
+ const result = map()
148
+ // injected1 has order 1, so it's spliced ahead of the panel's own (unordered) content
149
+ expect(result[0].element.props.items.map(i => i.id)).toEqual(['injected1', 'p1'])
150
+ })
151
+
152
+ it('returns just the injected controls when the panel has neither render nor html', () => {
153
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, element: <span>injected</span> }])
154
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'] } })
155
+ const result = map()
156
+ expect(result[0].element.props.items.map(i => i.id)).toEqual(['injected1'])
157
+ })
158
+
159
+ it('does not build an items list for a static-html panel (dangerouslySetInnerHTML can\'t host injected controls)', () => {
160
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'], pluginId: 'plug1', html: '<p>Hi</p>' } })
161
+ const result = map()
162
+ expect(result[0].element.props.items).toBeUndefined()
163
+ expect(result[0].element.props.html).toBe('<p>Hi</p>')
164
+ })
165
+
166
+ it('warns in dev when controls target a static-html panel', () => {
167
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, element: <span>injected</span> }])
168
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'], pluginId: 'plug1', html: '<p>Hi</p>' } })
169
+ map()
170
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('p1'))
171
+ })
172
+
173
+ it('orders multiple injected controls even when the panel has no render (regression: previously returned mapControls output unordered)', () => {
174
+ mapControls.mockReturnValue([
175
+ { id: 'first', order: 2, element: <span>first</span> },
176
+ { id: 'second', order: 1, element: <span>second</span> }
177
+ ])
178
+ defaultAppState.panelConfig = ({ p1: { desktop: { slot: 'header' }, includeModes: ['view'] } })
179
+ const result = map()
180
+ expect(result[0].element.props.items.map(i => i.id)).toEqual(['second', 'first'])
181
+ })
182
+
183
+ describe('tabs', () => {
184
+ it('does not build a tabs list when everything shares one (or no) tab', () => {
185
+ const renderFn = () => <div>child</div>
186
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, element: <span>injected</span> }])
187
+ defaultAppState.panelConfig = ({ p1: { ...baseConfig, render: renderFn } })
188
+ const result = map()
189
+ expect(result[0].element.props.tabs).toBeUndefined()
190
+ expect(result[0].element.props.items).toBeDefined()
191
+ })
192
+
193
+ it('groups the panel\'s own content and an injected control into separate tabs', () => {
194
+ const renderFn = () => <div>child</div>
195
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, tab: 'Injected', element: <span>injected</span> }])
196
+ defaultAppState.panelConfig = ({
197
+ p1: { ...baseConfig, render: renderFn, desktop: { slot: 'header', order: 1, tab: 'Own' } }
198
+ })
199
+ const result = map()
200
+ expect(result[0].element.props.items).toBeUndefined()
201
+ const tabNames = result[0].element.props.tabs.map(t => t.name)
202
+ expect(tabNames.sort()).toEqual(['Injected', 'Own'])
203
+ })
204
+
205
+ it('falls back to the panel\'s own label for its content\'s implicit tab', () => {
206
+ const renderFn = () => <div>child</div>
207
+ mapControls.mockReturnValue([{ id: 'injected1', order: 0, tab: 'Injected', element: <span>injected</span> }])
208
+ defaultAppState.panelConfig = ({
209
+ p1: { ...baseConfig, render: renderFn, label: 'Map styles' }
210
+ })
211
+ const result = map()
212
+ const tabNames = result[0].element.props.tabs.map(t => t.name)
213
+ expect(tabNames).toContain('Map styles')
214
+ })
122
215
  })
123
216
 
124
217
  it('returns correct structure and defaults', () => {
@@ -0,0 +1,28 @@
1
+ // src/core/renderers/orderItems.js
2
+
3
+ /**
4
+ * Orders a list of `{ id, order, element }` items for a single render target
5
+ * (a layout slot, or a panel body).
6
+ *
7
+ * Items without an explicit `order` (or `order === 0`) keep their natural/registration
8
+ * sequence. Items with an explicit `order` are spliced into that 1-based position among
9
+ * the unordered items, clamped to the valid range.
10
+ */
11
+ export function orderItems (items) {
12
+ // Start with items that have no explicit order (in their natural sequence)
13
+ const withoutOrder = items.filter(item => item.order == null || item.order === 0)
14
+ const withOrder = items.filter(item => item.order != null && item.order !== 0)
15
+
16
+ // Sort items with order by their order value (stable sort preserves ties)
17
+ withOrder.sort((a, b) => a.order - b.order)
18
+
19
+ // Insert each ordered item at its specified position
20
+ const result = [...withoutOrder]
21
+ for (const item of withOrder) {
22
+ // Clamp position to valid range (1-based, so subtract 1 for array index)
23
+ const pos = Math.min(Math.max(item.order - 1, 0), result.length)
24
+ result.splice(pos, 0, item)
25
+ }
26
+
27
+ return result
28
+ }
@@ -0,0 +1,50 @@
1
+ import { orderItems } from './orderItems.js'
2
+
3
+ describe('orderItems', () => {
4
+ it('keeps unordered items in their natural sequence', () => {
5
+ const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]
6
+ expect(orderItems(items).map(i => i.id)).toEqual(['a', 'b', 'c'])
7
+ })
8
+
9
+ it('treats order 0 the same as no order', () => {
10
+ const items = [{ id: 'a', order: 0 }, { id: 'b' }]
11
+ expect(orderItems(items).map(i => i.id)).toEqual(['a', 'b'])
12
+ })
13
+
14
+ it('inserts an ordered item at its 1-based position among unordered items', () => {
15
+ const items = [{ id: 'a' }, { id: 'b' }, { id: 'c', order: 1 }]
16
+ expect(orderItems(items).map(i => i.id)).toEqual(['c', 'a', 'b'])
17
+ })
18
+
19
+ it('splices ordered items in ascending order, each relative to the growing result', () => {
20
+ const items = [
21
+ { id: 'a' },
22
+ { id: 'b', order: 2 },
23
+ { id: 'c', order: 1 }
24
+ ]
25
+ // c (order 1) is spliced in first, ahead of 'a': [c, a]
26
+ // b (order 2) is then spliced at index 1 of that 2-item result: [c, b, a]
27
+ expect(orderItems(items).map(i => i.id)).toEqual(['c', 'b', 'a'])
28
+ })
29
+
30
+ it('clamps an order larger than the list to the end', () => {
31
+ const items = [{ id: 'a' }, { id: 'b' }, { id: 'c', order: 99 }]
32
+ expect(orderItems(items).map(i => i.id)).toEqual(['a', 'b', 'c'])
33
+ })
34
+
35
+ it('splices equal-order items each at position 0, reversing their relative order', () => {
36
+ // The stable sort keeps a before b, but with no unordered items to anchor against,
37
+ // each splice(0, 0, item) inserts ahead of the previous one.
38
+ const items = [{ id: 'a', order: 1 }, { id: 'b', order: 1 }]
39
+ expect(orderItems(items).map(i => i.id)).toEqual(['b', 'a'])
40
+ })
41
+
42
+ it('handles an all-ordered list', () => {
43
+ const items = [{ id: 'a', order: 2 }, { id: 'b', order: 1 }]
44
+ expect(orderItems(items).map(i => i.id)).toEqual(['b', 'a'])
45
+ })
46
+
47
+ it('handles an empty list', () => {
48
+ expect(orderItems([])).toEqual([])
49
+ })
50
+ })
@@ -2,28 +2,12 @@
2
2
  import { mapControls } from './mapControls.js'
3
3
  import { mapPanels } from './mapPanels.js'
4
4
  import { mapButtons } from './mapButtons.js'
5
+ import { orderItems } from './orderItems.js'
5
6
 
6
7
  export function getSlotItems ({ evaluateProp, ...args }) {
7
8
  const controls = mapControls({ evaluateProp, ...args })
8
9
  const panels = mapPanels({ evaluateProp, ...args })
9
10
  const buttons = mapButtons({ evaluateProp, ...args })
10
11
 
11
- const items = [...controls, ...panels, ...buttons]
12
-
13
- // Start with items that have no explicit order (in their natural sequence)
14
- const withoutOrder = items.filter(item => item.order == null || item.order === 0)
15
- const withOrder = items.filter(item => item.order != null && item.order !== 0)
16
-
17
- // Sort items with order by their order value (stable sort preserves ties)
18
- withOrder.sort((a, b) => a.order - b.order)
19
-
20
- // Insert each ordered item at its specified position
21
- const result = [...withoutOrder]
22
- for (const item of withOrder) {
23
- // Clamp position to valid range (1-based, so subtract 1 for array index)
24
- const pos = Math.min(Math.max(item.order - 1, 0), result.length)
25
- result.splice(pos, 0, item)
26
- }
27
-
28
- return result
12
+ return orderItems([...controls, ...panels, ...buttons])
29
13
  }
@@ -39,7 +39,9 @@ export const isControlVisible = (control, { breakpoint, mode, isFullscreen }) =>
39
39
  if (!bpConfig) {
40
40
  return false
41
41
  }
42
- if (!allowedSlots.control.includes(bpConfig.slot)) {
42
+ // A control may also target a panel's body directly via the `<panelId>-panel`
43
+ // slot convention (mirrors the `<buttonId>-button` convention panels already use).
44
+ if (!allowedSlots.control.includes(bpConfig.slot) && !bpConfig.slot?.endsWith('-panel')) {
43
45
  return false
44
46
  }
45
47
  if (!isModeAllowed(control, mode)) {
@@ -65,6 +65,11 @@ describe('isControlVisible', () => {
65
65
  it('returns true when inline:false and fullscreen', () => {
66
66
  expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', mode: 'view', isFullscreen: true })).toBe(true)
67
67
  })
68
+
69
+ it('returns true for a control targeting a panel-body slot via the <panelId>-panel convention', () => {
70
+ const panelTargeting = { desktop: { slot: 'map-styles-panel' } }
71
+ expect(isControlVisible(panelTargeting, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(true)
72
+ })
68
73
  })
69
74
 
70
75
  describe('isConsumerHtml', () => {
@@ -1,6 +1,7 @@
1
1
  // src/core/renderers/slots.js
2
2
  export const layoutSlots = Object.freeze({
3
3
  SIDE: 'side',
4
+ HEADER: 'header',
4
5
  BANNER: 'banner',
5
6
  TOP_LEFT: 'top-left',
6
7
  TOP_MIDDLE: 'top-middle',
@@ -19,6 +20,7 @@ export const layoutSlots = Object.freeze({
19
20
 
20
21
  export const allowedSlots = Object.freeze({
21
22
  control: [
23
+ layoutSlots.HEADER,
22
24
  layoutSlots.BANNER,
23
25
  layoutSlots.TOP_LEFT,
24
26
  layoutSlots.TOP_RIGHT,
@@ -31,6 +33,7 @@ export const allowedSlots = Object.freeze({
31
33
  ],
32
34
  panel: [
33
35
  layoutSlots.SIDE,
36
+ layoutSlots.HEADER,
34
37
  layoutSlots.BANNER,
35
38
  layoutSlots.LEFT_TOP,
36
39
  layoutSlots.LEFT_BOTTOM,
@@ -16,6 +16,7 @@ export const AppProvider = ({ options, children }) => {
16
16
  appContainerRef: useRef(null),
17
17
  sideRef: useRef(null),
18
18
  mainRef: useRef(null),
19
+ headerRef: useRef(null),
19
20
  topRef: useRef(null),
20
21
  topLeftColRef: useRef(null),
21
22
  topRightColRef: useRef(null),