@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
@@ -36,10 +36,48 @@ export const anchorToMaplibre = ([ax, ay]) => {
36
36
  return (y + (x && y ? '-' : '') + x) || 'center'
37
37
  }
38
38
 
39
+ // The discrete fraction (0, 0.5 or 1) icon-anchor actually renders a given axis at — the
40
+ // same left/right/top/bottom/center snapping xAnchor/yAnchor above already do, expressed as
41
+ // a number instead of a string so it can be compared against the true, unsnapped fraction.
42
+ const discreteFraction = (a) => {
43
+ if (a <= ANCHOR_LOW) { return 0 }
44
+ if (a >= ANCHOR_HIGH) { return 1 }
45
+ return 0.5
46
+ }
47
+
48
+ /**
49
+ * MapLibre's icon-anchor only has 9 discrete positions — anchorToMaplibre above snaps a
50
+ * fractional anchor to the nearest one, which loses precision for anything off-grid (e.g.
51
+ * pin's [0.5, 0.9] renders as if it were 1.0/'bottom', shifting the whole icon ~10% of its
52
+ * height off from where a DOM marker using the same anchor value would place it —
53
+ * SymbolMarker.jsx positions markers with the exact fraction via CSS margins, with no such
54
+ * snapping). icon-offset corrects this: the pixel distance, in the icon's own display-space
55
+ * dimensions (derived from its viewBox), between the true anchor and the discrete one it
56
+ * snapped to — applied on top of icon-anchor, on both provider and dataset symbol layers.
57
+ *
58
+ * @param {number[]} anchor - [x, y] in 0–1 space (the true, unsnapped anchor)
59
+ * @param {string} viewBox - SVG viewBox string, e.g. '0 0 44 44'
60
+ * @returns {number[]} [offsetX, offsetY] in pixels, for the icon-offset layout property
61
+ */
62
+ // Rounded to 2dp: sub-hundredth-of-a-pixel precision is meaningless for rendering, and
63
+ // without it fractions like 0.8 produce float noise (8.799999999999997, not 8.8).
64
+ const round2dp = (n) => Math.round(n * 100) / 100
65
+
66
+ export const anchorToMaplibreOffset = ([ax, ay], viewBox) => {
67
+ const [,, width, height] = viewBox.split(' ').map(Number)
68
+ return [
69
+ round2dp((discreteFraction(ax) - ax) * width),
70
+ round2dp((discreteFraction(ay) - ay) * height)
71
+ ]
72
+ }
73
+
39
74
  /**
40
75
  * Register normal, active (both rings) and selected (black ring) symbol images.
41
76
  * Skips images that are already registered (safe to call on style change).
42
- * Updates `map._activeSymbolImageMap` (normal→active) and `map._selectedSymbolImageMap` (normal→selected).
77
+ * Merges into `map._activeSymbolImageMap` (normal→active) and `map._selectedSymbolImageMap`
78
+ * (normal→selected) rather than replacing them — more than one caller (e.g. the datasets
79
+ * and draw plugins) can register symbols on the same map, and each call must add to that
80
+ * shared registry, not wipe out whatever another caller already registered there.
43
81
  *
44
82
  * @param {Object} map - MapLibre map instance
45
83
  * @param {Object[]} styleArray - an array of symbol configs
@@ -53,8 +91,8 @@ export const addSymbolsToMap = async (map, styleArray, mapStyle, symbolRegistry,
53
91
  return
54
92
  }
55
93
 
56
- map._activeSymbolImageMap = {}
57
- map._selectedSymbolImageMap = {}
94
+ map._activeSymbolImageMap ??= {}
95
+ map._selectedSymbolImageMap ??= {}
58
96
 
59
97
  await Promise.all(styleArray.flatMap(config => {
60
98
  const normalId = symbolRegistry.getSymbolImageId(config, mapStyle, false, pixelRatio)
@@ -1,4 +1,4 @@
1
- import { anchorToMaplibre, addSymbolsToMap } from './symbolImages.js'
1
+ import { anchorToMaplibre, anchorToMaplibreOffset, addSymbolsToMap } from './symbolImages.js'
2
2
  import { symbolRegistry } from '../../../../src/services/symbolRegistry.js'
3
3
 
4
4
  beforeAll(() => {
@@ -84,6 +84,36 @@ describe('anchorToMaplibre', () => {
84
84
  })
85
85
  })
86
86
 
87
+ // ─── anchorToMaplibreOffset ───────────────────────────────────────────────────
88
+
89
+ describe('anchorToMaplibreOffset', () => {
90
+ const VIEW_BOX = '0 0 44 44'
91
+
92
+ it('is zero for an anchor that already sits exactly on a discrete position', () => {
93
+ expect(anchorToMaplibreOffset([0.5, 0.5], VIEW_BOX)).toEqual([0, 0]) // center
94
+ expect(anchorToMaplibreOffset([0, 0], VIEW_BOX)).toEqual([0, 0]) // top-left
95
+ expect(anchorToMaplibreOffset([1, 1], VIEW_BOX)).toEqual([0, 0]) // bottom-right
96
+ })
97
+
98
+ it('corrects for pin-style [0.5, 0.9] snapping to bottom (1.0)', () => {
99
+ expect(anchorToMaplibreOffset([0.5, 0.9], VIEW_BOX)).toEqual([0, 4.4])
100
+ })
101
+
102
+ it('corrects both axes when both are off-grid', () => {
103
+ // 0.1 snaps to 'left'/'top' (0), 0.9 snaps to 'right'/'bottom' (1)
104
+ expect(anchorToMaplibreOffset([0.1, 0.1], VIEW_BOX)).toEqual([-4.4, -4.4])
105
+ expect(anchorToMaplibreOffset([0.9, 0.9], VIEW_BOX)).toEqual([4.4, 4.4])
106
+ })
107
+
108
+ it('still corrects a value inside the centre band that is not exactly 0.5 — the whole band snaps to 0.5, not just its edges', () => {
109
+ expect(anchorToMaplibreOffset([0.4, 0.6], VIEW_BOX)).toEqual([4.4, -4.4])
110
+ })
111
+
112
+ it('scales with the viewBox, not just the anchor fraction', () => {
113
+ expect(anchorToMaplibreOffset([0.5, 0.9], '0 0 100 100')).toEqual([0, 10])
114
+ })
115
+ })
116
+
87
117
  // ─── addSymbolsToMap ──────────────────────────────────────────────────────────
88
118
 
89
119
  const makeMap = (existingIds = []) => ({
@@ -103,13 +133,17 @@ describe('addSymbolsToMap — registration', () => {
103
133
  expect(map.addImage).not.toHaveBeenCalled()
104
134
  })
105
135
 
106
- it('resets _activeSymbolImageMap and _selectedSymbolImageMap before processing', async () => {
136
+ it('merges into existing _activeSymbolImageMap/_selectedSymbolImageMap rather than replacing them', async () => {
137
+ // More than one caller (datasets, draw, ...) can register symbols on the same map —
138
+ // a later call must add to the shared registry, not wipe an earlier caller's entries.
107
139
  const map = makeMap()
108
- map._activeSymbolImageMap = { stale: 'entry' }
109
- map._selectedSymbolImageMap = { stale: 'entry' }
140
+ map._activeSymbolImageMap = { 'other-normal-id': 'other-active-id' }
141
+ map._selectedSymbolImageMap = { 'other-normal-id': 'other-selected-id' }
110
142
  await addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
111
- expect(map._activeSymbolImageMap).not.toHaveProperty('stale')
112
- expect(map._selectedSymbolImageMap).not.toHaveProperty('stale')
143
+ expect(map._activeSymbolImageMap['other-normal-id']).toBe('other-active-id')
144
+ expect(map._selectedSymbolImageMap['other-normal-id']).toBe('other-selected-id')
145
+ const normalId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, false)
146
+ expect(map._activeSymbolImageMap).toHaveProperty(normalId)
113
147
  })
114
148
 
115
149
  it('calls addImage for normal, active and selected variants', async () => {
package/rollup.esm.mjs CHANGED
@@ -182,7 +182,7 @@ const createESMConfig = (entryPath, outDir, isCore = false, manualChunks = null,
182
182
  open: false,
183
183
  gzipSize: true
184
184
  })]
185
- : [])
185
+ : [])
186
186
  ],
187
187
 
188
188
  output: {
@@ -303,6 +303,16 @@ const ALL_BUILDS = [
303
303
  entryPath: './plugins/beta/frame/src/index.js',
304
304
  outDir: 'plugins/beta/frame/dist/esm',
305
305
  manualChunks: (id) => id.includes('/manifest') ? 'im-frame-plugin' : undefined
306
+ },
307
+ {
308
+ entryPath: './plugins/map-key/src/index.js',
309
+ outDir: 'plugins/map-key/dist/esm',
310
+ manualChunks: (id) => id.includes('/manifest') ? 'im-map-key-plugin' : undefined
311
+ },
312
+ {
313
+ entryPath: './plugins/menu/src/index.js',
314
+ outDir: 'plugins/menu/dist/esm',
315
+ manualChunks: (id) => id.includes('/manifest') ? 'im-menu-plugin' : undefined
306
316
  }
307
317
  ]
308
318
 
@@ -1,11 +1,16 @@
1
1
  import React from 'react'
2
2
  import { render, screen } from '@testing-library/react'
3
3
  import { Actions } from './Actions.jsx'
4
+ import { mapButtons } from '../../renderer/mapButtons.js'
4
5
  import * as AppContext from '../../store/appContext' // import the module to mock
5
6
 
6
7
  // Simple child component to handle isHidden
7
8
  const TestChild = ({ isHidden, children, ...props }) => <div {...props}>{children}</div>
8
9
 
10
+ jest.mock('../MapButton/MapButton.jsx', () => ({
11
+ MapButton: ({ isHidden }) => <button data-testid='map-button' hidden={isHidden} />
12
+ }))
13
+
9
14
  const ACTIONS_SELECTOR = '.im-c-actions'
10
15
  const HIDDEN_CLASS = 'im-c-actions--hidden'
11
16
 
@@ -80,4 +85,46 @@ describe('Actions component', () => {
80
85
  const container = screen.getByTestId('child1').closest(ACTIONS_SELECTOR)
81
86
  expect(container).toHaveClass(HIDDEN_CLASS)
82
87
  })
88
+
89
+ // Integration with the real mapButtons() output, not just the TestChild stand-in above —
90
+ // guards the isHidden/variant contract at the actual seam between the two modules, since
91
+ // mapButtons.js's own unit tests can't catch a break here on their own.
92
+ describe('with real mapButtons() output', () => {
93
+ const evaluateProp = (prop) => (typeof prop === 'function' ? prop() : prop)
94
+ const appConfig = { id: 'test' }
95
+ const baseAppState = {
96
+ breakpoint: 'desktop',
97
+ mode: 'view',
98
+ isFullscreen: true,
99
+ openPanels: {},
100
+ dispatch: jest.fn(),
101
+ disabledButtons: new Set(),
102
+ hiddenButtons: new Set(),
103
+ pressedButtons: new Set(),
104
+ expandedButtons: new Set(),
105
+ panelConfig: {},
106
+ layoutRefs: { viewportRef: { current: { focus: jest.fn() } } }
107
+ }
108
+
109
+ const renderActionsWith = (appState) => {
110
+ const items = mapButtons({ slot: 'actions', appState, appConfig, evaluateProp })
111
+ return render(<Actions slot='actions'>{items.map(i => i.element)}</Actions>)
112
+ }
113
+
114
+ it('shows the actions bar when a real mapButtons() button is visible', () => {
115
+ const appState = { ...baseAppState, buttonConfig: { cancel: { label: 'Cancel', desktop: { slot: 'actions' } } } }
116
+ renderActionsWith(appState)
117
+ expect(screen.getByTestId('map-button').closest(ACTIONS_SELECTOR)).not.toHaveClass(HIDDEN_CLASS)
118
+ })
119
+
120
+ it('hides the actions bar when the only real mapButtons() button is hidden', () => {
121
+ const appState = {
122
+ ...baseAppState,
123
+ hiddenButtons: new Set(['cancel']),
124
+ buttonConfig: { cancel: { label: 'Cancel', desktop: { slot: 'actions' } } }
125
+ }
126
+ renderActionsWith(appState)
127
+ expect(screen.getByTestId('map-button').closest(ACTIONS_SELECTOR)).toHaveClass(HIDDEN_CLASS)
128
+ })
129
+ })
83
130
  })
@@ -52,6 +52,19 @@ describe('Hints — rendering', () => {
52
52
  expect(desc.innerHTML).toBe('Shift + ?')
53
53
  })
54
54
 
55
+ it('appends the move-controls hint text when enableMoveControls is true', () => {
56
+ const mainEl = setup()
57
+ useConfig.mockReturnValue({
58
+ id: 'test-map',
59
+ keyboardHintText: '<kbd>Shift</kbd> + <kbd>?</kbd>',
60
+ enableMoveControls: true,
61
+ moveControlsHintText: '<kbd>Arrow keys</kbd> to move'
62
+ })
63
+ render(<Hints />)
64
+ const desc = mainEl.querySelector('#test-map-keyboard-desc')
65
+ expect(desc.innerHTML).toBe('Shift + ? Arrow keys to move')
66
+ })
67
+
55
68
  it('renders no hint content when there is no active hint', () => {
56
69
  const mainEl = setup()
57
70
  render(<Hints />)
@@ -3,6 +3,9 @@ import React from 'react'
3
3
  import { useConfig } from '../../store/configContext'
4
4
  import { useApp } from '../../store/appContext.js'
5
5
  import { Tabs } from '../Tabs/Tabs.jsx'
6
+ import { groupIntoTabs } from '../../renderer/groupIntoTabs.js'
7
+
8
+ const DEFAULT_GROUP = 'Navigate'
6
9
 
7
10
  const ShortcutList = ({ items }) => (
8
11
  <dl className='im-c-keyboard-help__list'>
@@ -15,26 +18,22 @@ const ShortcutList = ({ items }) => (
15
18
  </dl>
16
19
  )
17
20
 
18
- const buildGroupMap = (shortcuts) => shortcuts.reduce((acc, shortcut) => {
19
- const group = shortcut.group || 'Navigate'
20
- if (!acc[group]) {
21
- acc[group] = []
22
- }
23
- acc[group].push(shortcut)
24
- return acc
25
- }, {})
26
-
27
- const getDefaultTab = (groupEntries, context) => {
28
- const exactMatch = groupEntries.find(([, items]) =>
29
- items.some(s => (s.context ?? 'viewport') === context)
21
+ /**
22
+ * Picks which tab should be active on open: the first tab containing a shortcut whose own
23
+ * context matches exactly, else the first tab containing a global-context shortcut, else
24
+ * just the first tab. Domain-specific to keyboard shortcuts — not part of groupIntoTabs.
25
+ */
26
+ const getDefaultTab = (tabs, context) => {
27
+ const exactMatch = tabs.find(tab =>
28
+ tab.items.some(({ shortcut }) => (shortcut.context ?? 'viewport') === context)
30
29
  )
31
30
  if (exactMatch) {
32
- return exactMatch[0]
31
+ return exactMatch.name
33
32
  }
34
- const globalMatch = groupEntries.find(([, items]) =>
35
- items.some(s => s.context === 'global')
33
+ const globalMatch = tabs.find(tab =>
34
+ tab.items.some(({ shortcut }) => shortcut.context === 'global')
36
35
  )
37
- return globalMatch?.[0] ?? groupEntries[0][0]
36
+ return globalMatch?.name ?? tabs[0].name
38
37
  }
39
38
 
40
39
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
@@ -46,10 +45,13 @@ export const KeyboardHelp = ({ context = 'viewport' }) => { // NOSONAR: project
46
45
  const shortcuts = listboxIsActive
47
46
  ? allShortcuts
48
47
  : allShortcuts.filter(s => s.context !== 'listbox')
49
- const groupMap = buildGroupMap(shortcuts)
50
- const groupEntries = Object.entries(groupMap)
51
48
 
52
- if (groupEntries.length <= 1) {
49
+ const tabs = groupIntoTabs({
50
+ items: shortcuts.map(shortcut => ({ id: shortcut.id, order: 0, tab: shortcut.group, shortcut })),
51
+ fallbackLabel: DEFAULT_GROUP
52
+ })
53
+
54
+ if (!tabs) {
53
55
  return (
54
56
  <div className='im-c-keyboard-help'>
55
57
  <ShortcutList items={shortcuts} />
@@ -57,14 +59,14 @@ export const KeyboardHelp = ({ context = 'viewport' }) => { // NOSONAR: project
57
59
  )
58
60
  }
59
61
 
60
- const tabs = groupEntries.map(([name, items]) => ({
61
- name,
62
- content: <ShortcutList items={items} />
62
+ const tabProps = tabs.map(tab => ({
63
+ name: tab.name,
64
+ content: <ShortcutList items={tab.items.map(({ shortcut }) => shortcut)} />
63
65
  }))
64
66
 
65
67
  return (
66
68
  <div className='im-c-keyboard-help'>
67
- <Tabs tabs={tabs} defaultTab={getDefaultTab(groupEntries, context)} />
69
+ <Tabs tabs={tabProps} defaultTab={getDefaultTab(tabs, context)} />
68
70
  </div>
69
71
  )
70
72
  }
@@ -116,11 +116,11 @@ describe('KeyboardHelp — tabs', () => {
116
116
  expect(screen.getByRole('tab', { name: 'Group A' })).toHaveAttribute('aria-selected', 'true')
117
117
  })
118
118
 
119
- it('shows only the active tab panel content', () => {
119
+ it('shows only the active tab panel content — the inactive one stays in the DOM (so its aria-controls target exists) but hidden', () => {
120
120
  getKeyboardShortcuts.mockReturnValue(allShortcuts)
121
121
  render(<KeyboardHelp context='viewport' />)
122
- expect(screen.getByText('Move')).toBeInTheDocument()
123
- expect(screen.queryByText('Select')).not.toBeInTheDocument()
122
+ expect(screen.getByText('Move')).toBeVisible()
123
+ expect(screen.getByText('Select')).not.toBeVisible()
124
124
  })
125
125
 
126
126
  it('switches tab and content on click', () => {
@@ -128,8 +128,8 @@ describe('KeyboardHelp — tabs', () => {
128
128
  render(<KeyboardHelp context='viewport' />)
129
129
  fireEvent.click(screen.getByRole('tab', { name: SELECT_FEATURES_GROUP }))
130
130
  expect(screen.getByRole('tab', { name: SELECT_FEATURES_GROUP })).toHaveAttribute('aria-selected', 'true')
131
- expect(screen.getByText('Select')).toBeInTheDocument()
132
- expect(screen.queryByText('Move')).not.toBeInTheDocument()
131
+ expect(screen.getByText('Select')).toBeVisible()
132
+ expect(screen.getByText('Move')).not.toBeVisible()
133
133
  })
134
134
 
135
135
  it('ungrouped shortcuts default to Navigate group', () => {
@@ -141,6 +141,23 @@ describe('KeyboardHelp — tabs', () => {
141
141
  fireEvent.click(screen.getByRole('tab', { name: DEFAULT_GROUP }))
142
142
  expect(screen.getByText('No group')).toBeInTheDocument()
143
143
  })
144
+
145
+ it('merges group names that differ only by case/whitespace into one tab (groupIntoTabs)', () => {
146
+ getKeyboardShortcuts.mockReturnValue([
147
+ { id: 'a', group: 'Select Features', context: 'listbox', title: 'Shortcut A', command: '<kbd>Ctrl+A</kbd>' },
148
+ { id: 'b', group: 'select features', context: 'listbox', title: 'Shortcut B', command: '<kbd>Ctrl+B</kbd>' },
149
+ ...VIEWPORT_SHORTCUTS
150
+ ])
151
+ render(<KeyboardHelp context='listbox' />)
152
+ // Two groups present (Select Features, Navigate) — not one, so tabs render at all
153
+ expect(screen.getAllByRole('tab')).toHaveLength(2)
154
+ // First-encountered member's raw group string wins as the tab's displayed name
155
+ const tab = screen.getByRole('tab', { name: 'Select Features' })
156
+ expect(tab).toBeInTheDocument()
157
+ fireEvent.click(tab)
158
+ expect(screen.getByText('Shortcut A')).toBeVisible()
159
+ expect(screen.getByText('Shortcut B')).toBeVisible()
160
+ })
144
161
  })
145
162
 
146
163
  // ─── listboxIsActive filtering ────────────────────────────────────────────────
@@ -46,6 +46,7 @@
46
46
  .im-c-move-controls__directions .im-c-button-wrapper--nudge-step-toggle {
47
47
  grid-column: 2;
48
48
  grid-row: 2;
49
+ z-index: 1;
49
50
  }
50
51
 
51
52
  .im-c-move-controls__directions .im-c-map-button--pan-up svg {
@@ -66,24 +67,49 @@
66
67
  // express a plus-shaped grid where the centre button touches all four neighbours —
67
68
  // clip-path is set directly per button instead, clipping only the side(s) where it
68
69
  // touches another button and leaving the exposed side(s) showing the shadow as normal.
69
- .im-c-move-controls__directions .im-c-button-wrapper--pan-up .im-c-map-button::before {
70
- clip-path: inset(-20px -20px 0 -20px); // touches nudge-step-toggle below
70
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-up .im-c-map-button {
71
+ border-bottom-left-radius: 0;
72
+ border-bottom-right-radius: 0;
73
+
74
+ &::before {
75
+ clip-path: inset(-20px -20px 0 -20px); // touches nudge-step-toggle below
76
+ }
71
77
  }
72
78
 
73
- .im-c-move-controls__directions .im-c-button-wrapper--pan-left .im-c-map-button::before {
74
- clip-path: inset(-20px 0 -20px -20px); // touches nudge-step-toggle to the right
79
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-left .im-c-map-button {
80
+ border-top-right-radius: 0;
81
+ border-bottom-right-radius: 0;
82
+
83
+ &::before {
84
+ clip-path: inset(-20px 0 -20px -20px); // touches nudge-step-toggle to the right
85
+ }
75
86
  }
76
87
 
77
- .im-c-move-controls__directions .im-c-button-wrapper--pan-right .im-c-map-button::before {
78
- clip-path: inset(-20px -20px -20px 0); // touches nudge-step-toggle to the left
88
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-right .im-c-map-button {
89
+ border-top-left-radius: 0;
90
+ border-bottom-left-radius: 0;
91
+
92
+ &::before {
93
+ clip-path: inset(-20px -20px -20px 0); // touches nudge-step-toggle to the left
94
+ }
79
95
  }
80
96
 
81
- .im-c-move-controls__directions .im-c-button-wrapper--pan-down .im-c-map-button::before {
82
- clip-path: inset(0 -20px -20px -20px); // touches nudge-step-toggle above
97
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-down .im-c-map-button {
98
+ border-top-left-radius: 0;
99
+ border-top-right-radius: 0;
100
+
101
+ &::before {
102
+ clip-path: inset(0 -20px -20px -20px); // touches nudge-step-toggle above
103
+ }
83
104
  }
84
105
 
85
- .im-c-move-controls__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button::before {
86
- clip-path: inset(0); // touches all four neighbours
106
+ .im-c-move-controls__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button {
107
+ border-radius: 0;
108
+ outline: 1px solid var(--button-hover-color);
109
+
110
+ &::before {
111
+ clip-path: inset(0); // touches all four neighbours
112
+ }
87
113
  }
88
114
 
89
115
  // Same inverted "pressed" treatment already used for actions-slot toggle buttons
@@ -129,3 +155,8 @@
129
155
  .im-c-move-controls__zoom .im-c-button-wrapper--nudge-zoom-out .im-c-map-button {
130
156
  @include tools.border-focus-corner-override($corners: 'right');
131
157
  }
158
+
159
+ // App dark modifiers
160
+ .im-o-app--dark-app .im-c-move-controls__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button {
161
+ outline-offset: -1px;
162
+ }
@@ -5,6 +5,7 @@ import { stringToKebab } from '../../../utils/stringToKebab.js'
5
5
  import { useModalPanelBehaviour } from '../../hooks/useModalPanelBehaviour.js'
6
6
  import { useIsScrollable } from '../../hooks/useIsScrollable.js'
7
7
  import { Icon } from '../Icon/Icon'
8
+ import { Tabs } from '../Tabs/Tabs.jsx'
8
9
 
9
10
  const computePanelState = (bpConfig, triggeringElement, focus, focusOnOpen) => {
10
11
  const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
@@ -56,9 +57,56 @@ const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }
56
57
  'aria-labelledby': isBodyScrollable ? `${elementId}-label` : undefined
57
58
  })
58
59
 
60
+ // Renders the panel body's content: an ordered list of items (own content plus any
61
+ // controls injected via the `<panelId>-panel` slot convention), or the legacy single
62
+ // WrappedChild/children shape for callers that don't build an items list (e.g. HtmlElementHost).
63
+ // Tabbed content is handled separately by Panel itself — see the tabs handling below — since
64
+ // the tablist needs to render outside this component's scrollable body, not inside it.
65
+ const BodyContent = ({ items, WrappedChild, props, children }) => { // NOSONAR
66
+ if (items) {
67
+ return items.map(item => <React.Fragment key={item.id}>{item.element}</React.Fragment>)
68
+ }
69
+ return WrappedChild ? <WrappedChild {...props} /> : children
70
+ }
71
+
72
+ // Chooses between the three body shapes: static html, tabbed, or the flat BodyContent above.
73
+ // .im-c-panel__body (bodyProps) always wraps everything, completely unchanged — same padding/
74
+ // overflow/box role it's always had, which is also what the tablist and tabpanel need to pick
75
+ // up their own inset from by simply being nested inside it. Only the scrollable-region
76
+ // behaviour (ref/tabIndex/role/aria-labelledby) moves down onto <Tabs>' own tabpanel div via
77
+ // panelProps when tabbed, since that's the part that should actually scroll/receive focus —
78
+ // the tablist (rendered by <Tabs> itself, ahead of the tabpanel) stays outside that inner
79
+ // scroll boundary so it can't scroll away with long tab content. The tabpanel's own focus ring
80
+ // clearance (so it isn't flush against its content) is a CSS-only concern — see
81
+ // Panel.module.scss's .im-c-tabs__panel override.
82
+ const PanelBody = ({ innerHtmlProp, tabs, items, WrappedChild, props, children, bodyProps, panelBodySlot }) => { // NOSONAR
83
+ if (innerHtmlProp) {
84
+ return <div {...bodyProps} dangerouslySetInnerHTML={innerHtmlProp} /> // nosonar
85
+ }
86
+ const { className, ...scrollableProps } = bodyProps
87
+ if (tabs) {
88
+ return (
89
+ <div className={className} data-panel-slot={panelBodySlot}>
90
+ <Tabs
91
+ tabs={tabs.map(tab => ({
92
+ name: tab.name,
93
+ content: tab.items.map(item => <React.Fragment key={item.id}>{item.element}</React.Fragment>)
94
+ }))}
95
+ panelProps={scrollableProps}
96
+ />
97
+ </div>
98
+ )
99
+ }
100
+ return (
101
+ <div {...bodyProps} data-panel-slot={panelBodySlot}> {/* nosonar */}
102
+ <BodyContent items={items} WrappedChild={WrappedChild} props={props}>{children}</BodyContent>
103
+ </div>
104
+ )
105
+ }
106
+
59
107
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
60
108
  // sonarjs/disable-next-line function-name
61
- export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild, label, html, children, isOpen = true, rootRef }) => {
109
+ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild, items, tabs, label, html, children, isOpen = true, rootRef }) => {
62
110
  const { id } = useConfig()
63
111
  const { dispatch, breakpoint, layoutRefs, interfaceType } = useApp()
64
112
 
@@ -103,6 +151,10 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
103
151
 
104
152
  const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot })
105
153
  const bodyProps = buildBodyProps({ bodyRef, panelBodyClass, isBodyScrollable, elementId })
154
+ // DOM anchor for controls DOM-projected via the JS/consumer-HTML API — see HtmlElementHost.jsx.
155
+ // Only present on the items-capable body below: dangerouslySetInnerHTML owns the static-html
156
+ // body's children, so it can't also host controls injected via the panel-slot convention.
157
+ const panelBodySlot = `${stringToKebab(panelId)}-panel`
106
158
 
107
159
  return (
108
160
  <div // nosonar
@@ -118,6 +170,7 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
118
170
 
119
171
  {isDismissible && (
120
172
  <button
173
+ type='button'
121
174
  aria-label={`Close ${label}`}
122
175
  className='im-c-panel__close'
123
176
  onClick={handleClose}
@@ -126,13 +179,17 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
126
179
  </button>
127
180
  )}
128
181
 
129
- {innerHtmlProp
130
- ? <div {...bodyProps} dangerouslySetInnerHTML={innerHtmlProp} /> // nosonar
131
- : (
132
- <div {...bodyProps}> {/* nosonar */}
133
- {WrappedChild ? <WrappedChild {...props} /> : children}
134
- </div>
135
- )}
182
+ <PanelBody
183
+ innerHtmlProp={innerHtmlProp}
184
+ tabs={tabs}
185
+ items={items}
186
+ WrappedChild={WrappedChild}
187
+ props={props}
188
+ bodyProps={bodyProps}
189
+ panelBodySlot={panelBodySlot}
190
+ >
191
+ {children}
192
+ </PanelBody>
136
193
  </div>
137
194
  )
138
195
  }
@@ -18,6 +18,7 @@
18
18
  min-height: 0;
19
19
  min-width: var(--min-panel-width);
20
20
  pointer-events: auto;
21
+ color: var(--foreground-color);
21
22
  background-color: var(--background-color);
22
23
  }
23
24
 
@@ -58,15 +59,39 @@
58
59
  .im-c-panel__body {
59
60
  padding: 0 var(--panel-margin) var(--panel-margin);
60
61
  overflow: auto;
62
+ // display: flex so a tabbed .im-c-tabs child (see Tabs.module.scss) has a flex context to
63
+ // size itself within — its tablist keeps its natural height, only its tabpanel grows and
64
+ // scrolls internally, rather than this whole element (tablist included) scrolling as one.
65
+ // A single non-tabbed child is unaffected — one flex item in a column sizes the same as a
66
+ // plain block child would.
67
+ display: flex;
68
+ flex-direction: column;
69
+ }
70
+
71
+ // The tabpanel (see Tabs.module.scss) is its own scroll/focus region nested inside this
72
+ // already-padded wrapper — its content already sits correctly inset just by being nested, but
73
+ // its own border box (where the focus ring is drawn, and where its own overflow: auto clips)
74
+ // would otherwise sit tight against that content, with no room of its own. Reaching out with a
75
+ // negative margin — matching this wrapper's own padding — and re-padding by the same amount
76
+ // gives it that room without moving the content: the negative margin and the padding cancel
77
+ // out visually, but the border box itself is now larger. This is a panel-specific override —
78
+ // <Tabs> used anywhere else would need the same treatment if this clearance matters there too.
79
+ .im-c-panel__body .im-c-tabs__panel {
80
+ margin: 0 calc(-1 * var(--panel-margin)) calc(-1 * var(--panel-margin));
81
+ padding: 0 var(--panel-margin) var(--panel-margin);
82
+ }
61
83
 
62
- &[data-focus-visible="true"] {
63
- outline: var(--scrollable-outline);
64
- outline-offset: var(--scrollable-outline-offset);
65
- box-shadow: var(--scrollable-box-shadow);
84
+ // The scrollable-region focus ring — normally on .im-c-panel__body itself, but when tabbed,
85
+ // the tabpanel (see Tabs.module.scss's .im-c-tabs__panel) is the element that's actually
86
+ // focusable/scrollable instead (see Panel.jsx's PanelBody), so it needs the same treatment.
87
+ .im-c-panel__body[data-focus-visible="true"],
88
+ .im-c-tabs__panel[data-focus-visible="true"] {
89
+ outline: var(--scrollable-outline);
90
+ outline-offset: var(--scrollable-outline-offset);
91
+ box-shadow: var(--scrollable-box-shadow);
66
92
 
67
- @media (-ms-high-contrast:active), screen and (forced-colors: active) {
68
- outline-width: 4px;
69
- }
93
+ @media (-ms-high-contrast:active), screen and (forced-colors: active) {
94
+ outline-width: 4px;
70
95
  }
71
96
  }
72
97
 
@@ -98,19 +123,28 @@
98
123
  );
99
124
  }
100
125
 
101
- // When banner heading is hidden we need to align with the close button
102
- .im-o-app--desktop .im-c-panel--banner.im-c-panel--no-heading {
103
- .im-c-panel__close {
104
- top: var(--banner-close-button-gap);
105
- right: var(--banner-close-button-gap);
126
+ // Banner gets small rounded corners instead of the default square panel look.
127
+ .im-c-panel--banner {
128
+ &,
129
+ &::before {
130
+ border-radius: var(--banner-border-radius);
106
131
  }
107
132
 
108
- .im-c-panel__body--offset {
109
- padding-top: 10px;
110
- padding-bottom: 10px;
133
+ &::after {
134
+ border-radius: calc(var(--banner-border-radius) - 2px);
111
135
  }
112
136
  }
113
137
 
138
+ // No heading means no built-in top spacing, so add it back here.
139
+ .im-c-panel--no-heading .im-c-panel__body {
140
+ padding-top: var(--panel-margin);
141
+ }
142
+
143
+ // Ensures the body is tall enough to contain the close button.
144
+ .im-c-panel--no-heading .im-c-panel__body--offset {
145
+ min-height: calc((var(--panel-close-button-gap) * 2) + var(--button-size));
146
+ }
147
+
114
148
  .im-c-panel__body--offset {
115
149
  padding-right: calc(var(--button-size) + var(--divider-gap));
116
150
  }