@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
@@ -69,7 +69,7 @@ describe('Panel', () => {
69
69
  const { container } = renderPanel()
70
70
 
71
71
  // 2. Target by class to avoid role collision with the parent panel
72
- const body = container.querySelector('.im-c-panel__body')
72
+ const body = container.querySelector('[data-panel-slot]')
73
73
 
74
74
  expect(body).toHaveAttribute('tabIndex', '0')
75
75
  expect(body).toHaveAttribute('role', 'region')
@@ -194,5 +194,118 @@ describe('Panel', () => {
194
194
  renderPanel({}, { html: '<p>HTML content</p>' })
195
195
  expect(screen.getByText('HTML content')).toBeInTheDocument()
196
196
  })
197
+
198
+ it('renders each items entry in list order', () => {
199
+ const items = [
200
+ { id: 'a', element: <p>First</p> },
201
+ { id: 'b', element: <p>Second</p> }
202
+ ]
203
+ renderPanel({}, { items })
204
+ expect(screen.getByText('First')).toBeInTheDocument()
205
+ expect(screen.getByText('Second')).toBeInTheDocument()
206
+ })
207
+
208
+ it('prefers items over WrappedChild/children when provided', () => {
209
+ const Wrapped = () => <p>Wrapped</p>
210
+ renderPanel({}, {
211
+ items: [{ id: 'a', element: <p>Item</p> }],
212
+ WrappedChild: Wrapped,
213
+ children: <p>Child content</p>
214
+ })
215
+ expect(screen.getByText('Item')).toBeInTheDocument()
216
+ expect(screen.queryByText('Wrapped')).not.toBeInTheDocument()
217
+ expect(screen.queryByText('Child content')).not.toBeInTheDocument()
218
+ })
219
+
220
+ it('stamps data-panel-slot on the items-capable body, for controls DOM-projected via the JS API', () => {
221
+ const { container } = renderPanel({}, { items: [{ id: 'a', element: <p>Item</p> }] })
222
+ const body = container.querySelector('[data-panel-slot]')
223
+ expect(body).toHaveAttribute('data-panel-slot', 'settings-panel')
224
+ })
225
+
226
+ it('does not stamp data-panel-slot on a static-html body', () => {
227
+ const { container } = renderPanel({}, { html: '<p>HTML content</p>' })
228
+ const body = container.querySelector('.im-c-panel__body')
229
+ expect(body).not.toHaveAttribute('data-panel-slot')
230
+ })
231
+
232
+ describe('tabs', () => {
233
+ const tabs = [
234
+ { name: 'First', items: [{ id: 'a', element: <p>First content</p> }] },
235
+ { name: 'Second', items: [{ id: 'b', element: <p>Second content</p> }] }
236
+ ]
237
+
238
+ it('renders items grouped into tabs when the tabs prop is provided', () => {
239
+ renderPanel({}, { tabs })
240
+ expect(screen.getByRole('tablist')).toBeInTheDocument()
241
+ expect(screen.getByRole('tab', { name: 'First' })).toBeInTheDocument()
242
+ expect(screen.getByRole('tab', { name: 'Second' })).toBeInTheDocument()
243
+ expect(screen.getByText('First content')).toBeInTheDocument()
244
+ })
245
+
246
+ it('switches tab content when a different tab is activated', () => {
247
+ renderPanel({}, { tabs })
248
+ fireEvent.click(screen.getByRole('tab', { name: 'Second' }))
249
+ expect(screen.getByText('Second content')).toBeVisible()
250
+ expect(screen.getByText('First content')).not.toBeVisible()
251
+ })
252
+
253
+ it('prefers tabs over items/WrappedChild/children when provided', () => {
254
+ renderPanel({}, {
255
+ tabs,
256
+ items: [{ id: 'x', element: <p>Flat item</p> }],
257
+ WrappedChild: () => <p>Wrapped</p>,
258
+ children: <p>Child content</p>
259
+ })
260
+ expect(screen.getByText('First content')).toBeInTheDocument()
261
+ expect(screen.queryByText('Flat item')).not.toBeInTheDocument()
262
+ expect(screen.queryByText('Wrapped')).not.toBeInTheDocument()
263
+ expect(screen.queryByText('Child content')).not.toBeInTheDocument()
264
+ })
265
+
266
+ it('keeps the panel body wrapper (padding, data-panel-slot) around the whole tabs block', () => {
267
+ const { container } = renderPanel({}, { tabs })
268
+ const body = container.querySelector('[data-panel-slot]')
269
+ expect(body).toHaveAttribute('data-panel-slot', 'settings-panel')
270
+ expect(body.querySelector('[role="tablist"]')).not.toBeNull()
271
+ expect(body.querySelector('[role="tabpanel"]')).not.toBeNull()
272
+ })
273
+
274
+ it('keeps the outer wrapper as the normal, unconditional im-c-panel__body — the tablist and tabpanel pick up their inset by being nested inside it, same as any other panel content', () => {
275
+ const { container } = renderPanel({}, { tabs })
276
+ const body = container.querySelector('[data-panel-slot]')
277
+ expect(body).toHaveClass('im-c-panel__body')
278
+ })
279
+
280
+ it('keeps the tablist outside the scrollable tabpanel, so it does not scroll away with long tab content', () => {
281
+ const { container } = renderPanel({}, { tabs })
282
+ const tabpanel = container.querySelector('[role="tabpanel"]')
283
+ expect(tabpanel.querySelector('[role="tablist"]')).toBeNull()
284
+ })
285
+
286
+ it('places the tablist before the tabpanel in DOM order, so keyboard focus reaches it first', () => {
287
+ const { container } = renderPanel({}, { tabs })
288
+ const tablist = container.querySelector('[role="tablist"]')
289
+ const tabpanel = container.querySelector('[role="tabpanel"]')
290
+ // DOCUMENT_POSITION_FOLLOWING (4) means `tabpanel` comes after `tablist`
291
+ // eslint-disable-next-line no-bitwise
292
+ expect(tablist.compareDocumentPosition(tabpanel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
293
+ })
294
+
295
+ it('moves the scrollable-region treatment onto the tabpanel itself, not the outer wrapper', () => {
296
+ useIsScrollable.mockReturnValue(true)
297
+
298
+ const { container } = renderPanel({}, { tabs })
299
+ const body = container.querySelector('[data-panel-slot]')
300
+ const tabpanel = screen.getByRole('tabpanel')
301
+
302
+ expect(tabpanel).toHaveAttribute('tabIndex', '0')
303
+ expect(body).not.toHaveAttribute('tabIndex')
304
+ expect(body).not.toHaveAttribute('role', 'region')
305
+ expect(tabpanel).not.toHaveAttribute('data-panel-slot')
306
+
307
+ useIsScrollable.mockReturnValue(false)
308
+ })
309
+ })
197
310
  })
198
311
  })
@@ -1,18 +1,31 @@
1
1
  import React, { useState, useCallback } from 'react'
2
+ import { useConfig } from '../../store/configContext'
2
3
 
3
- const toTabId = (name) => `im-c-tabs-tab-${name.toLowerCase().replaceAll(/\s+/g, '-')}`
4
- const toPanelId = (name) => `im-c-tabs-panel-${name.toLowerCase().replaceAll(/\s+/g, '-')}`
4
+ // Prefixed with the app's own id (not the im-c- BEM prefix, which is for CSS classes, not DOM
5
+ // ids see Panel.jsx's/MapButton.jsx's own ${id}-... ids for the same convention) so these
6
+ // stay unique when more than one map instance is on the same page.
7
+ const toTabId = (idPrefix, name) => `${idPrefix}-tabs-tab-${name.toLowerCase().replaceAll(/\s+/g, '-')}`
8
+ const toPanelId = (idPrefix, name) => `${idPrefix}-tabs-panel-${name.toLowerCase().replaceAll(/\s+/g, '-')}`
5
9
 
10
+ /**
11
+ * @param {object} [panelProps] Extra props merged onto the tabpanel div (not the tablist) —
12
+ * e.g. a scrollable container's ref/className/tabIndex, for a caller (Panel.jsx) that needs
13
+ * that behaviour scoped to just the active tab's content, not the tablist above it. `id`,
14
+ * `role` and `aria-labelledby` are structural to the tabs pattern and always win over any
15
+ * same-named key in `panelProps`; `tabIndex` defers to `panelProps.tabIndex` when provided,
16
+ * since a scrollable tabpanel legitimately wants tabIndex 0 rather than the -1 default.
17
+ */
6
18
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
7
19
  // sonarjs/disable-next-line function-name
8
- export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use PropTypes
20
+ export const Tabs = ({ tabs, defaultTab, panelProps }) => { // NOSONAR: project does not use PropTypes
21
+ const { id: idPrefix } = useConfig()
9
22
  const names = tabs.map(t => t.name)
10
23
  const [activeTab, setActiveTab] = useState(defaultTab ?? names[0])
11
24
 
12
25
  const activateTab = useCallback((name) => {
13
26
  setActiveTab(name)
14
- document.getElementById(toTabId(name))?.focus()
15
- }, [])
27
+ document.getElementById(toTabId(idPrefix, name))?.focus()
28
+ }, [idPrefix])
16
29
 
17
30
  const handleKeyDown = useCallback((e) => {
18
31
  const idx = names.indexOf(activeTab)
@@ -33,7 +46,7 @@ export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use
33
46
  }
34
47
  }, [names, activeTab, activateTab])
35
48
 
36
- const activeContent = tabs.find(t => t.name === activeTab)?.content
49
+ const panelClassName = ['im-c-tabs__panel', panelProps?.className].filter(Boolean).join(' ')
37
50
 
38
51
  return (
39
52
  <div className='im-c-tabs'>
@@ -42,10 +55,10 @@ export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use
42
55
  <button
43
56
  type='button'
44
57
  key={name}
45
- id={toTabId(name)}
58
+ id={toTabId(idPrefix, name)}
46
59
  role='tab'
47
60
  aria-selected={activeTab === name}
48
- aria-controls={toPanelId(name)}
61
+ aria-controls={toPanelId(idPrefix, name)}
49
62
  tabIndex={activeTab === name ? 0 : -1}
50
63
  className='im-c-tabs__tab'
51
64
  onClick={() => activateTab(name)}
@@ -55,15 +68,28 @@ export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use
55
68
  </button>
56
69
  ))}
57
70
  </div>
58
- <div
59
- id={toPanelId(activeTab)}
60
- role='tabpanel'
61
- aria-labelledby={toTabId(activeTab)}
62
- tabIndex={-1} // nosonar panel has no focusable children; -1 per ARIA tabs pattern (AT reads via role/aria-labelledby)
63
- className='im-c-tabs__panel'
64
- >
65
- {activeContent}
66
- </div>
71
+ {/* All tabpanels render, hidden unless active — not swapped in/out — so every tab's
72
+ aria-controls always resolves to a real element, rather than only the active one's.
73
+ panelProps (ref/tabIndex — a scrollable container's, from a caller like Panel.jsx)
74
+ can only meaningfully target one DOM node, so it's applied to the active panel only;
75
+ className is shared by all of them so there's nothing to restyle on switch. */}
76
+ {tabs.map(({ name, content }) => {
77
+ const isActive = activeTab === name
78
+ return (
79
+ <div
80
+ key={name}
81
+ ref={isActive ? panelProps?.ref : undefined}
82
+ id={toPanelId(idPrefix, name)}
83
+ role='tabpanel'
84
+ aria-labelledby={toTabId(idPrefix, name)}
85
+ tabIndex={isActive ? (panelProps?.tabIndex ?? -1) : -1} // nosonar — panel has no focusable children by default (-1); a scrollable active one wants 0, via panelProps
86
+ hidden={!isActive}
87
+ className={panelClassName}
88
+ >
89
+ {content}
90
+ </div>
91
+ )
92
+ })}
67
93
  </div>
68
94
  )
69
95
  }
@@ -4,9 +4,18 @@
4
4
  // Component: Tabs
5
5
  // ===================================================
6
6
 
7
+ // Lets a caller (e.g. Panel.jsx) give Tabs a bounded height — only .im-c-tabs__panel scrolls.
8
+ .im-c-tabs {
9
+ display: flex;
10
+ flex-direction: column;
11
+ min-height: 0;
12
+ flex: 1 1 auto;
13
+ }
14
+
7
15
  .im-c-tabs__list {
8
16
  position: relative;
9
17
  display: flex;
18
+ flex: 0 0 auto;
10
19
  flex-wrap: wrap;
11
20
  padding: 0;
12
21
  margin: 0;
@@ -14,8 +23,14 @@
14
23
  z-index: 1;
15
24
  }
16
25
 
26
+ .im-c-tabs__panel {
27
+ flex: 1 1 auto;
28
+ overflow: auto;
29
+ }
30
+
17
31
  .im-c-tabs__tab {
18
32
  position: relative;
33
+ z-index: 0; // scopes the active tab's ::before (below) to this button's own stacking context
19
34
  appearance: none;
20
35
  background: var(--button-hover-color);
21
36
  border: 1px solid transparent;
@@ -42,12 +57,24 @@
42
57
  }
43
58
  }
44
59
 
60
+ // The selected tab looks taller via an absolutely-positioned ::before rather than changing
61
+ // the button's own box, so the label never moves and the tablist's height never shifts.
45
62
  &[aria-selected='true'] {
46
- background: var(--background-color);
47
- border-color: var(--app-border-color);
48
- border-bottom-color: var(--background-color);
49
- margin-top: 0;
50
- margin-bottom: -1px;
63
+ z-index: 1; // sit above unselected siblings
64
+ background: transparent;
65
+
66
+ &::before {
67
+ content: '';
68
+ position: absolute;
69
+ z-index: -1;
70
+ top: -5px;
71
+ right: -1px;
72
+ bottom: -6px;
73
+ left: -1px;
74
+ background: var(--background-color);
75
+ border: 1px solid var(--app-border-color);
76
+ border-bottom-color: var(--background-color);
77
+ }
51
78
  }
52
79
 
53
80
  &:focus .im-c-tabs__label {
@@ -1,11 +1,18 @@
1
1
  import React from 'react'
2
2
  import { render, screen, fireEvent } from '@testing-library/react'
3
3
  import { Tabs } from './Tabs'
4
+ import { useConfig } from '../../store/configContext'
5
+
6
+ jest.mock('../../store/configContext', () => ({ useConfig: jest.fn() }))
4
7
 
5
8
  const TAB_A = { name: 'Alpha', content: <p>Alpha content</p> }
6
9
  const TAB_B = { name: 'Beta', content: <p>Beta content</p> }
7
10
  const TAB_C = { name: 'Gamma', content: <p>Gamma content</p> }
8
11
 
12
+ beforeEach(() => {
13
+ useConfig.mockReturnValue({ id: 'test-app' })
14
+ })
15
+
9
16
  // ─── rendering ───────────────────────────────────────────────────────────────
10
17
 
11
18
  describe('Tabs — rendering', () => {
@@ -15,17 +22,17 @@ describe('Tabs — rendering', () => {
15
22
  expect(screen.getByRole('tab', { name: 'Beta' })).toBeInTheDocument()
16
23
  })
17
24
 
18
- it('shows content for the active tab only', () => {
25
+ it('shows content for the active tab only — the inactive one stays in the DOM (so its aria-controls target exists) but hidden', () => {
19
26
  render(<Tabs tabs={[TAB_A, TAB_B]} />)
20
- expect(screen.getByText('Alpha content')).toBeInTheDocument()
21
- expect(screen.queryByText('Beta content')).not.toBeInTheDocument()
27
+ expect(screen.getByText('Alpha content')).toBeVisible()
28
+ expect(screen.getByText('Beta content')).not.toBeVisible()
22
29
  })
23
30
 
24
31
  it('uses defaultTab to set the initial active tab', () => {
25
32
  render(<Tabs tabs={[TAB_A, TAB_B]} defaultTab='Beta' />)
26
33
  expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'true')
27
- expect(screen.getByText('Beta content')).toBeInTheDocument()
28
- expect(screen.queryByText('Alpha content')).not.toBeInTheDocument()
34
+ expect(screen.getByText('Beta content')).toBeVisible()
35
+ expect(screen.getByText('Alpha content')).not.toBeVisible()
29
36
  })
30
37
 
31
38
  it('falls back to first tab when defaultTab is not provided', () => {
@@ -41,8 +48,8 @@ describe('Tabs — click behaviour', () => {
41
48
  render(<Tabs tabs={[TAB_A, TAB_B]} />)
42
49
  fireEvent.click(screen.getByRole('tab', { name: 'Beta' }))
43
50
  expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'true')
44
- expect(screen.getByText('Beta content')).toBeInTheDocument()
45
- expect(screen.queryByText('Alpha content')).not.toBeInTheDocument()
51
+ expect(screen.getByText('Beta content')).toBeVisible()
52
+ expect(screen.getByText('Alpha content')).not.toBeVisible()
46
53
  })
47
54
  })
48
55
 
@@ -55,10 +62,33 @@ describe('Tabs — WCAG attributes', () => {
55
62
  expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('tabindex', '-1')
56
63
  })
57
64
 
58
- it('each tab has aria-controls pointing to an element in the DOM', () => {
65
+ it('every tab has aria-controls pointing to a real element — including inactive tabs, since their panel stays in the DOM (hidden) rather than being unmounted', () => {
66
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
67
+ const activeTab = screen.getByRole('tab', { name: 'Alpha' })
68
+ const inactiveTab = screen.getByRole('tab', { name: 'Beta' })
69
+ expect(document.getElementById(activeTab.getAttribute('aria-controls'))).toBeInTheDocument()
70
+ expect(document.getElementById(inactiveTab.getAttribute('aria-controls'))).toBeInTheDocument()
71
+ })
72
+
73
+ it('gives every tabpanel its own stable id, distinct per tab, not just the active one', () => {
74
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
75
+ const activeTab = screen.getByRole('tab', { name: 'Alpha' })
76
+ const inactiveTab = screen.getByRole('tab', { name: 'Beta' })
77
+ expect(activeTab.getAttribute('aria-controls')).not.toBe(inactiveTab.getAttribute('aria-controls'))
78
+ })
79
+
80
+ it('hides the inactive tabpanel via the hidden attribute rather than unmounting it', () => {
81
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
82
+ const inactiveTab = screen.getByRole('tab', { name: 'Beta' })
83
+ const inactivePanel = document.getElementById(inactiveTab.getAttribute('aria-controls'))
84
+ expect(inactivePanel).toHaveAttribute('hidden')
85
+ })
86
+
87
+ it("labels every tabpanel with its own tab, not just the active one's", () => {
59
88
  render(<Tabs tabs={[TAB_A, TAB_B]} />)
60
- const tab = screen.getByRole('tab', { name: 'Alpha' })
61
- expect(document.getElementById(tab.getAttribute('aria-controls'))).toBeInTheDocument()
89
+ const inactiveTab = screen.getByRole('tab', { name: 'Beta' })
90
+ const inactivePanel = document.getElementById(inactiveTab.getAttribute('aria-controls'))
91
+ expect(inactivePanel.getAttribute('aria-labelledby')).toBe(inactiveTab.id)
62
92
  })
63
93
 
64
94
  it('panel has aria-labelledby pointing to the active tab', () => {
@@ -45,34 +45,32 @@ describe('Tooltip', () => {
45
45
 
46
46
  it('shows and hides tooltip on mouse interactions', () => {
47
47
  const { button, tooltip } = renderTooltip()
48
- act(() => fireEvent.mouseEnter(button))
48
+ fireEvent.mouseEnter(button)
49
49
  advanceTimers()
50
50
  expect(tooltip).toHaveClass('im-c-tooltip--is-visible')
51
51
  expect(getTooltipPosition).toHaveBeenCalled()
52
- act(() => fireEvent.mouseLeave(button))
52
+ fireEvent.mouseLeave(button)
53
53
  advanceTimers(0)
54
54
  expect(tooltip).not.toHaveClass('im-c-tooltip--is-visible')
55
55
  })
56
56
 
57
57
  it('cancels tooltip on mouse down or key down', () => {
58
58
  const { button, tooltip } = renderTooltip()
59
- act(() => {
60
- fireEvent.mouseEnter(button)
61
- fireEvent.mouseDown(button)
62
- fireEvent.mouseEnter(button)
63
- fireEvent.keyDown(button)
64
- })
59
+ fireEvent.mouseEnter(button)
60
+ fireEvent.mouseDown(button)
61
+ fireEvent.mouseEnter(button)
62
+ fireEvent.keyDown(button)
65
63
  advanceTimers()
66
64
  expect(tooltip).not.toHaveClass('im-c-tooltip--is-visible')
67
65
  })
68
66
 
69
67
  it('handles focus interactions for keyboard interface', () => {
70
68
  const { button, tooltip, wrapper } = renderTooltip({}, 'keyboard')
71
- act(() => fireEvent.focus(button))
69
+ fireEvent.focus(button)
72
70
  advanceTimers()
73
71
  expect(wrapper).toHaveClass('im-c-tooltip-wrapper--has-focus')
74
72
  expect(tooltip).toHaveClass('im-c-tooltip--is-visible')
75
- act(() => fireEvent.blur(button))
73
+ fireEvent.blur(button)
76
74
  advanceTimers(0)
77
75
  expect(wrapper).not.toHaveClass('im-c-tooltip-wrapper--has-focus')
78
76
  expect(tooltip).not.toHaveClass('im-c-tooltip--is-visible')
@@ -80,22 +78,22 @@ describe('Tooltip', () => {
80
78
 
81
79
  it('does not show tooltip on focus if interfaceType is mouse', () => {
82
80
  const { button, tooltip } = renderTooltip()
83
- act(() => fireEvent.focus(button))
81
+ fireEvent.focus(button)
84
82
  advanceTimers()
85
83
  expect(tooltip).not.toHaveClass('im-c-tooltip--is-visible')
86
84
  })
87
85
 
88
86
  it('hides tooltip on Escape key', () => {
89
87
  const { button, tooltip } = renderTooltip()
90
- act(() => fireEvent.mouseEnter(button))
88
+ fireEvent.mouseEnter(button)
91
89
  advanceTimers()
92
- act(() => fireEvent.keyDown(window, { key: 'Escape' }))
90
+ fireEvent.keyDown(window, { key: 'Escape' })
93
91
  expect(tooltip).not.toHaveClass('im-c-tooltip--is-visible')
94
92
  })
95
93
 
96
94
  it('removes keydown listener on unmount', () => {
97
95
  const { button, unmount } = renderTooltip({}, 'keyboard')
98
- act(() => fireEvent.mouseEnter(button))
96
+ fireEvent.mouseEnter(button)
99
97
  advanceTimers()
100
98
  const removeSpy = jest.spyOn(window, 'removeEventListener')
101
99
  unmount()
@@ -1,9 +1,12 @@
1
1
  import { useState, useEffect } from 'react'
2
2
 
3
+ const SET_FEATURES = 'map:setfeatures'
4
+ const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed'
5
+
3
6
  /**
4
- * Subscribes to map:setfeatures and exposes the current listbox item list and multiselectable flag.
5
- * The list is produced by useMapItemList in the interact plugin and pushed via the event bus
6
- * whenever the visible set of markers or features changes.
7
+ * Subscribes to map:setfeatures for the listbox item list, and map:setfeaturessuppressed
8
+ * (any plugin can emit this) to report items as empty while suppressed regardless of what's
9
+ * actually visible which is what drives <Features>'s own tabIndex/aria-hidden.
7
10
  *
8
11
  * @param {object} eventBus
9
12
  * @returns {{ items: Array<{ id: string, label: string }>, multiselectable: boolean }}
@@ -11,20 +14,26 @@ import { useState, useEffect } from 'react'
11
14
  export function useFeatureItems (eventBus) {
12
15
  const [items, setItems] = useState([])
13
16
  const [multiselectable, setMultiselectable] = useState(false)
17
+ const [suppressed, setSuppressed] = useState(false)
14
18
 
15
19
  useEffect(() => {
16
20
  if (!eventBus) {
17
21
  return undefined
18
22
  }
19
- const handle = ({ items: next = [], multiselectable: nextMultiselectable = false }) => {
23
+ const handleSetFeatures = ({ items: next = [], multiselectable: nextMultiselectable = false }) => {
20
24
  setItems(next)
21
25
  setMultiselectable(nextMultiselectable)
22
26
  }
23
- eventBus.on('map:setfeatures', handle)
27
+ const handleSuppressed = ({ suppressed: next = false } = {}) => {
28
+ setSuppressed(next)
29
+ }
30
+ eventBus.on(SET_FEATURES, handleSetFeatures)
31
+ eventBus.on(SET_FEATURES_SUPPRESSED, handleSuppressed)
24
32
  return () => {
25
- eventBus.off('map:setfeatures', handle)
33
+ eventBus.off(SET_FEATURES, handleSetFeatures)
34
+ eventBus.off(SET_FEATURES_SUPPRESSED, handleSuppressed)
26
35
  }
27
36
  }, [eventBus])
28
37
 
29
- return { items, multiselectable }
38
+ return { items: suppressed ? [] : items, multiselectable }
30
39
  }
@@ -2,6 +2,7 @@ import { renderHook, act } from '@testing-library/react'
2
2
  import { useFeatureItems } from './useFeatureItems.js'
3
3
 
4
4
  const SET_FEATURES = 'map:setfeatures' // NOSONAR
5
+ const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed' // NOSONAR
5
6
 
6
7
  const makeEventBus = () => {
7
8
  const listeners = {}
@@ -37,11 +38,18 @@ describe('useFeatureItems — event subscription', () => {
37
38
  expect(eb.on).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
38
39
  })
39
40
 
41
+ it('subscribes to map:setfeaturessuppressed on mount', () => {
42
+ const eb = makeEventBus()
43
+ renderHook(() => useFeatureItems(eb))
44
+ expect(eb.on).toHaveBeenCalledWith(SET_FEATURES_SUPPRESSED, expect.any(Function))
45
+ })
46
+
40
47
  it('unsubscribes on unmount', () => {
41
48
  const eb = makeEventBus()
42
49
  const { unmount } = renderHook(() => useFeatureItems(eb))
43
50
  unmount()
44
51
  expect(eb.off).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
52
+ expect(eb.off).toHaveBeenCalledWith(SET_FEATURES_SUPPRESSED, expect.any(Function))
45
53
  })
46
54
 
47
55
  it('does not subscribe when eventBus is undefined', () => {
@@ -96,3 +104,52 @@ describe('useFeatureItems — multiselectable updates', () => {
96
104
  expect(result.current.multiselectable).toBe(false)
97
105
  })
98
106
  })
107
+
108
+ // ─── useFeatureItems — suppression ───────────────────────────────────────────
109
+
110
+ describe('useFeatureItems — suppression', () => {
111
+ const items = [{ id: 'a', label: 'Feature A' }]
112
+
113
+ it('reports empty items while suppressed, regardless of what was last set', () => {
114
+ const eb = makeEventBus()
115
+ const { result } = renderHook(() => useFeatureItems(eb))
116
+ act(() => eb.emit(SET_FEATURES, { items }))
117
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
118
+ expect(result.current.items).toEqual([])
119
+ })
120
+
121
+ it('restores the last known items once suppression is lifted', () => {
122
+ const eb = makeEventBus()
123
+ const { result } = renderHook(() => useFeatureItems(eb))
124
+ act(() => eb.emit(SET_FEATURES, { items }))
125
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
126
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
127
+ expect(result.current.items).toEqual(items)
128
+ })
129
+
130
+ it('defaults suppressed to false when the payload omits it', () => {
131
+ const eb = makeEventBus()
132
+ const { result } = renderHook(() => useFeatureItems(eb))
133
+ act(() => eb.emit(SET_FEATURES, { items }))
134
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, {}))
135
+ expect(result.current.items).toEqual(items)
136
+ })
137
+
138
+ it('defaults suppressed to false when the event carries no payload at all', () => {
139
+ const eb = makeEventBus()
140
+ const { result } = renderHook(() => useFeatureItems(eb))
141
+ act(() => eb.emit(SET_FEATURES, { items }))
142
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED))
143
+ expect(result.current.items).toEqual(items)
144
+ })
145
+
146
+ it('a features update received while suppressed is still applied once restored', () => {
147
+ const eb = makeEventBus()
148
+ const { result } = renderHook(() => useFeatureItems(eb))
149
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
150
+ act(() => eb.emit(SET_FEATURES, { items }))
151
+ expect(result.current.items).toEqual([]) // still suppressed
152
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
153
+ expect(result.current.items).toEqual(items)
154
+ })
155
+ })