@defra/interactive-map 0.0.43-fmp-experimental → 0.0.45-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 (297) hide show
  1. package/assets/templates/add-polygons.njk +11 -7
  2. package/assets/templates/add-symbols.njk +5 -1
  3. package/assets/templates/select-feature.njk +72 -15
  4. package/dist/css/index.css +1 -1
  5. package/dist/esm/im-core.js +1 -1
  6. package/dist/esm/im-shell.js +1 -1
  7. package/dist/umd/im-core.js +1 -1
  8. package/dist/umd/index.js +1 -1
  9. package/docs/api/context.md +24 -0
  10. package/docs/api/keyboard-shortcut-definition.md +102 -0
  11. package/docs/api.md +18 -8
  12. package/docs/building-a-plugin.md +1 -0
  13. package/docs/examples/add-marker-with-panel.mdx +4 -0
  14. package/docs/examples/add-polygons.mdx +16 -12
  15. package/docs/examples/add-symbols.mdx +4 -0
  16. package/docs/examples/basic-map.mdx +4 -0
  17. package/docs/examples/button-map.mdx +4 -0
  18. package/docs/examples/draw-tools.mdx +4 -0
  19. package/docs/examples/place-marker.mdx +4 -0
  20. package/docs/examples/search-control.mdx +4 -0
  21. package/docs/examples/select-feature.mdx +4 -0
  22. package/docs/examples/style-switcher.mdx +36 -44
  23. package/docs/examples/toggle-marker-label.mdx +4 -0
  24. package/docs/plugins/draw.md +2 -2
  25. package/docs/plugins/plugin-manifest.md +9 -0
  26. package/package.json +4 -2
  27. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  28. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -0
  29. package/plugins/beta/draw-es/src/events.js +17 -8
  30. package/plugins/beta/draw-es/src/reducer.js +8 -1
  31. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  32. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  33. package/plugins/beta/draw-ol/dist/css/index.css +1 -13
  34. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  35. package/plugins/beta/frame/dist/css/index.css +1 -11
  36. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  37. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  38. package/plugins/beta/frame/src/Frame.jsx +28 -2
  39. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  40. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  41. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  42. package/plugins/beta/scale-bar/dist/css/index.css +1 -8
  43. package/plugins/datasets/dist/css/5648.index.css +1 -5
  44. package/plugins/datasets/dist/css/index.css +1 -1
  45. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  46. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  47. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  48. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  49. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  50. package/plugins/datasets/src/registry/dataset.js +8 -12
  51. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  52. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  53. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  54. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  55. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  56. package/plugins/draw/dist/esm/index.js +1 -1
  57. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  58. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  59. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  60. package/plugins/draw/dist/umd/index.js +1 -1
  61. package/plugins/draw/src/DrawInit.jsx +41 -26
  62. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  63. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  64. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  65. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  66. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  67. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  68. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  69. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  70. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +23 -2
  71. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +53 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  73. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  74. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  75. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  76. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  77. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  78. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  79. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  80. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  86. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  87. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  88. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  89. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  90. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  91. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  92. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  93. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  94. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  95. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  96. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  97. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  98. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  99. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  100. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  101. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  102. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +40 -17
  103. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  104. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  105. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  106. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  107. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  108. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  109. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  110. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  111. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  112. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +40 -54
  113. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +32 -13
  114. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  115. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  116. package/plugins/draw/src/events.js +4 -4
  117. package/plugins/draw/src/events.test.js +1 -1
  118. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  119. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  120. package/plugins/draw/src/manifest.js +4 -1
  121. package/plugins/draw/src/manifest.test.js +19 -7
  122. package/plugins/draw/src/utils/spatial.js +1 -31
  123. package/plugins/draw/src/utils/spatial.test.js +1 -24
  124. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  125. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  126. package/plugins/interact/dist/umd/index.js +1 -1
  127. package/plugins/interact/src/InteractInit.jsx +2 -2
  128. package/plugins/interact/src/InteractInit.test.js +3 -3
  129. package/plugins/interact/src/events.js +11 -2
  130. package/plugins/interact/src/events.test.js +32 -0
  131. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  132. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  133. package/plugins/interact/src/hooks/useCrossHairVisibility.js +9 -4
  134. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  135. package/plugins/interact/src/hooks/useSpatialList.js +251 -0
  136. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +216 -70
  137. package/plugins/interact/src/manifest.js +12 -1
  138. package/plugins/interact/src/manifest.test.js +20 -0
  139. package/plugins/interact/src/utils/spatial.js +24 -1
  140. package/plugins/interact/src/utils/spatial.test.js +26 -1
  141. package/plugins/map-key/dist/css/index.css +1 -106
  142. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  143. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  144. package/plugins/map-key/dist/umd/index.js +1 -1
  145. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  146. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  147. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  148. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  149. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  150. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  151. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  152. package/plugins/map-key/src/manifest.js +1 -1
  153. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  154. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  155. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  156. package/plugins/menu/dist/css/index.css +1 -80
  157. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  158. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  159. package/plugins/menu/dist/umd/index.js +1 -1
  160. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  161. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  162. package/plugins/menu/src/initialise/createMenu.js +3 -1
  163. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  164. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  165. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  166. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  167. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  168. package/plugins/menu/src/reducers/pluginState.js +1 -6
  169. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  170. package/plugins/search/dist/css/index.css +1 -1
  171. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  172. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  173. package/plugins/search/src/components/Form/Form.jsx +3 -3
  174. package/plugins/search/src/components/Suggestions/Suggestions.jsx +2 -2
  175. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +1 -1
  176. package/plugins/search/src/manifest.js +5 -9
  177. package/plugins/search/src/manifest.test.js +5 -0
  178. package/providers/beta/esri/dist/css/index.css +1 -34
  179. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  180. package/providers/beta/esri/src/utils/spatial.js +15 -13
  181. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  182. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  183. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  184. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  185. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  186. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  187. package/providers/beta/openlayers/src/utils/spatial.js +16 -14
  188. package/providers/beta/openlayers/src/utils/spatial.test.js +10 -6
  189. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  190. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  191. package/providers/maplibre/dist/umd/index.js +1 -1
  192. package/providers/maplibre/src/defaults.test.js +26 -0
  193. package/providers/maplibre/src/maplibreProvider.js +1 -1
  194. package/providers/maplibre/src/utils/labels.js +2 -2
  195. package/providers/maplibre/src/utils/labels.test.js +5 -5
  196. package/providers/maplibre/src/utils/spatial.js +19 -74
  197. package/providers/maplibre/src/utils/spatial.test.js +8 -32
  198. package/rollup.esm.mjs +2 -1
  199. package/scripts/publish-package.sh +36 -0
  200. package/src/App/App.jsx +1 -1
  201. package/src/App/App.test.jsx +6 -1
  202. package/src/App/components/CrossHair/CrossHair.jsx +25 -11
  203. package/src/App/components/CrossHair/CrossHair.module.scss +37 -12
  204. package/src/App/components/CrossHair/CrossHair.test.jsx +40 -11
  205. package/src/App/components/Hints/Hints.jsx +3 -3
  206. package/src/App/components/Hints/Hints.test.jsx +3 -3
  207. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  208. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  209. package/src/App/components/MapButton/MapButton.jsx +19 -7
  210. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  211. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  212. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  213. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  214. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  215. package/src/App/components/Markers/Markers.jsx +2 -2
  216. package/src/App/components/Markers/Markers.test.jsx +3 -3
  217. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  218. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  219. package/src/App/components/Panel/Panel.jsx +19 -20
  220. package/src/App/components/Panel/Panel.module.scss +9 -0
  221. package/src/App/components/Panel/Panel.test.jsx +69 -4
  222. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  223. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  224. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  225. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  226. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  227. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +6 -6
  228. package/src/App/components/Viewport/SpatialList.test.jsx +285 -0
  229. package/src/App/components/Viewport/Viewport.jsx +21 -11
  230. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  231. package/src/App/controls/keyboardMappings.js +7 -2
  232. package/src/App/controls/keyboardMappings.test.js +21 -0
  233. package/src/App/controls/keyboardShortcuts.js +16 -2
  234. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  235. package/src/App/hooks/useCrossHairAPI.js +29 -1
  236. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  237. package/src/App/hooks/useHintsAPI.js +5 -5
  238. package/src/App/hooks/useInterfaceAPI.js +2 -1
  239. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  240. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  241. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  242. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  243. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  244. package/src/App/hooks/useSpatialListFocus.js +296 -0
  245. package/src/App/hooks/useSpatialListFocus.test.js +957 -0
  246. package/src/App/hooks/useSpatialListItems.js +51 -0
  247. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  248. package/src/App/initialiseApp.js +6 -3
  249. package/src/App/initialiseApp.test.js +11 -0
  250. package/src/App/layout/Layout.jsx +4 -2
  251. package/src/App/layout/Layout.test.jsx +21 -0
  252. package/src/App/layout/layout.module.scss +7 -1
  253. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  254. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  255. package/src/App/registry/pluginRegistry.js +13 -0
  256. package/src/App/registry/pluginRegistry.test.js +15 -0
  257. package/src/App/registry/spatialListRegistry.js +104 -0
  258. package/src/App/registry/spatialListRegistry.test.js +218 -0
  259. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  260. package/src/App/renderer/mapButtons.js +4 -0
  261. package/src/App/renderer/mapButtons.test.js +20 -0
  262. package/src/App/renderer/mapPanels.js +33 -53
  263. package/src/App/renderer/mapPanels.test.js +29 -4
  264. package/src/App/renderer/slotHelpers.js +33 -0
  265. package/src/App/renderer/slotHelpers.test.js +79 -2
  266. package/src/App/store/AppProvider.jsx +3 -2
  267. package/src/App/store/ServiceProvider.jsx +5 -3
  268. package/src/App/store/ServiceProvider.test.jsx +20 -1
  269. package/src/InteractiveMap/InteractiveMap.js +13 -2
  270. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  271. package/src/config/appConfig.js +17 -17
  272. package/src/config/appConfig.test.js +19 -19
  273. package/src/config/defaults.js +2 -2
  274. package/src/config/events.js +8 -8
  275. package/src/scss/main.scss +2 -2
  276. package/src/scss/tools/_govuk-content.scss +29 -0
  277. package/src/scss/tools/_index.scss +2 -1
  278. package/src/test-utils.js +8 -0
  279. package/src/types.js +53 -11
  280. package/src/utils/findNearestItemInDirection.js +29 -0
  281. package/src/utils/findNearestItemInDirection.test.js +67 -0
  282. package/src/utils/getPanelElementId.js +4 -0
  283. package/src/utils/getPanelElementId.test.js +11 -0
  284. package/src/utils/getPanelRole.js +22 -0
  285. package/src/utils/getPanelRole.test.js +56 -0
  286. package/src/utils/globalAltShortcuts.js +12 -0
  287. package/src/utils/globalAltShortcuts.test.js +27 -0
  288. package/src/utils/spatialNavigate.js +49 -0
  289. package/src/utils/spatialNavigate.test.js +42 -0
  290. package/webpack.umd.mjs +6 -1
  291. package/plugins/interact/src/hooks/useMapItemList.js +0 -187
  292. package/src/App/components/Viewport/Features.jsx +0 -35
  293. package/src/App/components/Viewport/Features.test.jsx +0 -124
  294. package/src/App/hooks/useFeatureFocus.js +0 -199
  295. package/src/App/hooks/useFeatureFocus.test.js +0 -635
  296. package/src/App/hooks/useFeatureItems.js +0 -39
  297. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -1,6 +1,6 @@
1
1
  import React from 'react'
2
2
  import { render, screen, fireEvent } from '@testing-library/react'
3
- import { MoveControls } from './MoveControls.jsx'
3
+ import { MapControls } from './MapControls.jsx'
4
4
  import { useApp } from '../../store/appContext.js'
5
5
  import { useConfig } from '../../store/configContext.js'
6
6
  import { useMap } from '../../store/mapContext.js'
@@ -11,19 +11,19 @@ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
11
11
  jest.mock('../../store/mapContext.js', () => ({ useMap: jest.fn() }))
12
12
  jest.mock('../../store/serviceContext.js', () => ({ useService: jest.fn() }))
13
13
 
14
- describe('MoveControls', () => {
14
+ describe('MapControls', () => {
15
15
  let dispatch
16
16
  let mapProvider
17
17
  let announce
18
18
 
19
19
  // MapButton and Tooltip also read from useApp() (buttonRefs, interfaceType), so every
20
- // mockReturnValue needs to carry these alongside the fields MoveControls itself reads.
20
+ // mockReturnValue needs to carry these alongside the fields MapControls itself reads.
21
21
  const buildAppState = (overrides) => ({
22
22
  buttonRefs: { current: {} },
23
23
  interfaceType: 'mouse',
24
24
  breakpoint: 'desktop',
25
25
  dispatch,
26
- expandedButtons: new Set(['moveControls']),
26
+ expandedButtons: new Set(['mapControls']),
27
27
  nudgeStepSize: 'large',
28
28
  layoutRefs: { viewportRef: { current: { focus: jest.fn() } } },
29
29
  ...overrides
@@ -50,29 +50,29 @@ describe('MoveControls', () => {
50
50
  afterEach(() => jest.clearAllMocks())
51
51
 
52
52
  it('renders with the id matching the trigger button aria-controls value', () => {
53
- const { container } = render(<MoveControls />)
54
- expect(container.querySelector('#im-move-controls-content')).toBeInTheDocument()
53
+ const { container } = render(<MapControls />)
54
+ expect(container.querySelector('#im-map-controls-content')).toBeInTheDocument()
55
55
  })
56
56
 
57
- it('is not visually collapsed when moveControls is expanded', () => {
58
- const { container } = render(<MoveControls />)
59
- expect(container.querySelector('.im-c-move-controls--collapsed')).not.toBeInTheDocument()
57
+ it('is not visually collapsed when mapControls is expanded', () => {
58
+ const { container } = render(<MapControls />)
59
+ expect(container.querySelector('.im-c-map-controls--collapsed')).not.toBeInTheDocument()
60
60
  })
61
61
 
62
62
  it('moves focus to the first direction button when the control opens', () => {
63
- const { rerender } = render(<MoveControls />)
63
+ const { rerender } = render(<MapControls />)
64
64
  useApp.mockReturnValue(buildAppState({ expandedButtons: new Set() }))
65
- rerender(<MoveControls />)
65
+ rerender(<MapControls />)
66
66
 
67
- useApp.mockReturnValue(buildAppState({ expandedButtons: new Set(['moveControls']) }))
68
- rerender(<MoveControls />)
67
+ useApp.mockReturnValue(buildAppState({ expandedButtons: new Set(['mapControls']) }))
68
+ rerender(<MapControls />)
69
69
  expect(screen.getByRole('button', { name: 'Move up' })).toHaveFocus()
70
70
  })
71
71
 
72
72
  it('renders the directions group before the zoom group at every breakpoint', () => {
73
73
  ['mobile', 'tablet', 'desktop'].forEach(breakpoint => {
74
74
  useApp.mockReturnValue(buildAppState({ breakpoint }))
75
- const { container, unmount } = render(<MoveControls />)
75
+ const { container, unmount } = render(<MapControls />)
76
76
  const groups = container.querySelectorAll('[role="group"]')
77
77
  expect(groups[0]).toHaveAttribute('aria-label', 'Direction controls')
78
78
  expect(groups[1]).toHaveAttribute('aria-label', 'Zoom controls')
@@ -80,14 +80,14 @@ describe('MoveControls', () => {
80
80
  })
81
81
  })
82
82
 
83
- it('is collapsed when moveControls is not expanded', () => {
83
+ it('is collapsed when mapControls is not expanded', () => {
84
84
  useApp.mockReturnValue(buildAppState({ expandedButtons: new Set() }))
85
- const { container } = render(<MoveControls />)
86
- expect(container.querySelector('.im-c-move-controls--collapsed')).toBeInTheDocument()
85
+ const { container } = render(<MapControls />)
86
+ expect(container.querySelector('.im-c-map-controls--collapsed')).toBeInTheDocument()
87
87
  })
88
88
 
89
89
  it('labels direction buttons "Move" and pans by the large delta by default', () => {
90
- render(<MoveControls />)
90
+ render(<MapControls />)
91
91
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
92
92
  expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
93
93
  expect(announce).toHaveBeenCalledWith('Moved right')
@@ -95,7 +95,7 @@ describe('MoveControls', () => {
95
95
 
96
96
  it('labels direction buttons "Nudge" and pans by the small delta when nudgeStepSize is small', () => {
97
97
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
98
- render(<MoveControls />)
98
+ render(<MapControls />)
99
99
  fireEvent.click(screen.getByRole('button', { name: 'Nudge up' }))
100
100
  expect(mapProvider.panBy).toHaveBeenCalledWith([0, -5])
101
101
  expect(announce).toHaveBeenCalledWith('Nudged up')
@@ -103,7 +103,7 @@ describe('MoveControls', () => {
103
103
 
104
104
  it('routes direction clicks to mapProvider.activeMoveTarget instead of panning, when a plugin has claimed it', () => {
105
105
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
106
- render(<MoveControls />)
106
+ render(<MapControls />)
107
107
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
108
108
  expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(1, 0, true)
109
109
  expect(mapProvider.panBy).not.toHaveBeenCalled()
@@ -112,22 +112,22 @@ describe('MoveControls', () => {
112
112
 
113
113
  it('falls back to panning once activeMoveTarget is released', () => {
114
114
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
115
- const { rerender } = render(<MoveControls />)
115
+ const { rerender } = render(<MapControls />)
116
116
  mapProvider.activeMoveTarget = null
117
- rerender(<MoveControls />)
117
+ rerender(<MapControls />)
118
118
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
119
119
  expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
120
120
  })
121
121
 
122
122
  it('omits the target label from the announcement when activeMoveTarget has none', () => {
123
123
  mapProvider.activeMoveTarget = { move: jest.fn() }
124
- render(<MoveControls />)
124
+ render(<MapControls />)
125
125
  fireEvent.click(screen.getByRole('button', { name: 'Move up' }))
126
126
  expect(announce).toHaveBeenCalledWith('Moved up')
127
127
  })
128
128
 
129
129
  it('zooms in and out by the large delta by default and announces the action', () => {
130
- render(<MoveControls />)
130
+ render(<MapControls />)
131
131
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
132
132
  expect(mapProvider.zoomIn).toHaveBeenCalledWith(1)
133
133
  expect(announce).toHaveBeenCalledWith('Zoomed in')
@@ -139,26 +139,26 @@ describe('MoveControls', () => {
139
139
 
140
140
  it('zooms by the small delta when nudgeStepSize is small', () => {
141
141
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
142
- render(<MoveControls />)
142
+ render(<MapControls />)
143
143
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
144
144
  expect(mapProvider.zoomIn).toHaveBeenCalledWith(0.1)
145
145
  })
146
146
 
147
147
  it('disables the zoom in button at max zoom, and zoom out at min zoom', () => {
148
148
  useMap.mockReturnValue({ isAtMaxZoom: true, isAtMinZoom: false })
149
- const { rerender } = render(<MoveControls />)
149
+ const { rerender } = render(<MapControls />)
150
150
  expect(screen.getByRole('button', { name: 'Zoom in' })).toHaveAttribute('aria-disabled', 'true')
151
151
  expect(screen.getByRole('button', { name: 'Zoom out' })).not.toHaveAttribute('aria-disabled')
152
152
 
153
153
  useMap.mockReturnValue({ isAtMaxZoom: false, isAtMinZoom: true })
154
- rerender(<MoveControls />)
154
+ rerender(<MapControls />)
155
155
  expect(screen.getByRole('button', { name: 'Zoom in' })).not.toHaveAttribute('aria-disabled')
156
156
  expect(screen.getByRole('button', { name: 'Zoom out' })).toHaveAttribute('aria-disabled', 'true')
157
157
  })
158
158
 
159
159
  it('does not zoom when the relevant button is disabled at max/min zoom', () => {
160
160
  useMap.mockReturnValue({ isAtMaxZoom: true, isAtMinZoom: true })
161
- render(<MoveControls />)
161
+ render(<MapControls />)
162
162
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
163
163
  fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
164
164
  expect(mapProvider.zoomIn).not.toHaveBeenCalled()
@@ -177,7 +177,7 @@ describe('MoveControls', () => {
177
177
  it('returns focus to the viewport after panning on mouse/touch, so arrow-key shortcuts elsewhere are not left stranded on the D-pad button', () => {
178
178
  const appState = buildAppState({ interfaceType: 'mouse' })
179
179
  useApp.mockReturnValue(appState)
180
- render(<MoveControls />)
180
+ render(<MapControls />)
181
181
 
182
182
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
183
183
  expect(appState.layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
@@ -185,19 +185,19 @@ describe('MoveControls', () => {
185
185
 
186
186
  it('keeps focus on the button when driven by keyboard, so repeated Enter/Space presses do not require re-tabbing', () => {
187
187
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
188
- render(<MoveControls />)
188
+ render(<MapControls />)
189
189
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
190
190
  expect(rafSpy).not.toHaveBeenCalled()
191
191
  })
192
192
 
193
193
  it('also returns focus after zooming on mouse/touch, but not on keyboard', () => {
194
194
  useApp.mockReturnValue(buildAppState({ interfaceType: 'mouse' }))
195
- const { rerender } = render(<MoveControls />)
195
+ const { rerender } = render(<MapControls />)
196
196
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
197
197
  expect(rafSpy).toHaveBeenCalledTimes(1)
198
198
 
199
199
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
200
- rerender(<MoveControls />)
200
+ rerender(<MapControls />)
201
201
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
202
202
  expect(rafSpy).toHaveBeenCalledTimes(1)
203
203
  })
@@ -205,7 +205,7 @@ describe('MoveControls', () => {
205
205
  it('also returns focus after a vertex nudge via activeMoveTarget on mouse/touch', () => {
206
206
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
207
207
  useApp.mockReturnValue(buildAppState({ interfaceType: 'touch' }))
208
- render(<MoveControls />)
208
+ render(<MapControls />)
209
209
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
210
210
  expect(rafSpy).toHaveBeenCalledTimes(1)
211
211
  })
@@ -217,7 +217,7 @@ describe('MoveControls', () => {
217
217
  // arrow keys without first tabbing all the way back out to the map.
218
218
  it('pans the map on an arrow key, regardless of which button currently has focus', () => {
219
219
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
220
- render(<MoveControls />)
220
+ render(<MapControls />)
221
221
  // Focus a button unrelated to the direction being pressed, to prove this
222
222
  // isn't just reading the focused button's own handler.
223
223
  fireEvent.focus(screen.getByRole('button', { name: 'Zoom in' }))
@@ -229,7 +229,7 @@ describe('MoveControls', () => {
229
229
  it('routes the arrow key through activeMoveTarget when a plugin has claimed the control', () => {
230
230
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
231
231
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
232
- render(<MoveControls />)
232
+ render(<MapControls />)
233
233
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp' })
234
234
  expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, true)
235
235
  expect(mapProvider.panBy).not.toHaveBeenCalled()
@@ -237,14 +237,14 @@ describe('MoveControls', () => {
237
237
 
238
238
  it('ignores non-arrow keys, leaving default behaviour (e.g. Enter/Space activating the focused button) untouched', () => {
239
239
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
240
- render(<MoveControls />)
240
+ render(<MapControls />)
241
241
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'Enter' })
242
242
  expect(mapProvider.panBy).not.toHaveBeenCalled()
243
243
  })
244
244
 
245
245
  it('shift+arrow overrides the Precision toggle to the small step, matching the map\'s own native keyboard shortcuts', () => {
246
246
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
247
- render(<MoveControls />)
247
+ render(<MapControls />)
248
248
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight', shiftKey: true })
249
249
  expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
250
250
  expect(announce).toHaveBeenCalledWith('Nudged right')
@@ -252,7 +252,7 @@ describe('MoveControls', () => {
252
252
 
253
253
  it('shift+arrow still resolves to the small step when Precision is already on (idempotent, not a toggle-relative flip)', () => {
254
254
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'small' }))
255
- render(<MoveControls />)
255
+ render(<MapControls />)
256
256
  fireEvent.keyDown(screen.getByRole('button', { name: 'Nudge right' }), { key: 'ArrowRight', shiftKey: true })
257
257
  expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
258
258
  expect(announce).toHaveBeenCalledWith('Nudged right')
@@ -261,14 +261,14 @@ describe('MoveControls', () => {
261
261
  it('shift+arrow overrides activeMoveTarget.move to the small step too', () => {
262
262
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
263
263
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
264
- render(<MoveControls />)
264
+ render(<MapControls />)
265
265
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp', shiftKey: true })
266
266
  expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, false)
267
267
  })
268
268
 
269
269
  it('arrow key without shift still follows the Precision toggle as before', () => {
270
270
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
271
- render(<MoveControls />)
271
+ render(<MapControls />)
272
272
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight' })
273
273
  expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
274
274
  expect(announce).toHaveBeenCalledWith('Moved right')
@@ -276,11 +276,11 @@ describe('MoveControls', () => {
276
276
  })
277
277
 
278
278
  it('has a stable "Precision" label regardless of state', () => {
279
- const { rerender } = render(<MoveControls />)
279
+ const { rerender } = render(<MapControls />)
280
280
  expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
281
281
 
282
282
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
283
- rerender(<MoveControls />)
283
+ rerender(<MapControls />)
284
284
  expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
285
285
  })
286
286
 
@@ -294,29 +294,29 @@ describe('MoveControls', () => {
294
294
  return document.getElementById(button.getAttribute('aria-labelledby'))
295
295
  }
296
296
 
297
- const { rerender } = render(<MoveControls />)
297
+ const { rerender } = render(<MapControls />)
298
298
  let tooltip = getOwnTooltip()
299
299
  expect(tooltip.querySelector('[aria-hidden="true"]')).toHaveTextContent('(Off)')
300
300
  expect(tooltip).toHaveTextContent('Precision (Off)')
301
301
 
302
302
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
303
- rerender(<MoveControls />)
303
+ rerender(<MapControls />)
304
304
  tooltip = getOwnTooltip()
305
305
  expect(tooltip.querySelector('[aria-hidden="true"]')).toHaveTextContent('(On)')
306
306
  expect(tooltip).toHaveTextContent('Precision (On)')
307
307
  })
308
308
 
309
309
  it('reflects precision mode via aria-pressed, not via label changes', () => {
310
- const { rerender } = render(<MoveControls />)
310
+ const { rerender } = render(<MapControls />)
311
311
  expect(screen.getByRole('button', { name: 'Precision' })).toHaveAttribute('aria-pressed', 'false')
312
312
 
313
313
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
314
- rerender(<MoveControls />)
314
+ rerender(<MapControls />)
315
315
  expect(screen.getByRole('button', { name: 'Precision' })).toHaveAttribute('aria-pressed', 'true')
316
316
  })
317
317
 
318
318
  it('toggles precision on and announces it when currently in large-step mode', () => {
319
- render(<MoveControls />)
319
+ render(<MapControls />)
320
320
  fireEvent.click(screen.getByRole('button', { name: 'Precision' }))
321
321
  expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_NUDGE_STEP' })
322
322
  expect(announce).toHaveBeenCalledWith('Precision on')
@@ -324,7 +324,7 @@ describe('MoveControls', () => {
324
324
 
325
325
  it('toggles precision off and announces it when currently in small-step mode', () => {
326
326
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
327
- render(<MoveControls />)
327
+ render(<MapControls />)
328
328
  fireEvent.click(screen.getByRole('button', { name: 'Precision' }))
329
329
  expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_NUDGE_STEP' })
330
330
  expect(announce).toHaveBeenCalledWith('Precision off')
@@ -67,11 +67,11 @@ export const Markers = () => {
67
67
  const handleSetActive = ({ id: markerId }) => setActiveMarkerId(markerId)
68
68
  eventBus.on('interact:active', handleActive)
69
69
  eventBus.on('interact:selectionchange', handleSelectionChange)
70
- eventBus.on(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
70
+ eventBus.on(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
71
71
  return () => {
72
72
  eventBus.off('interact:active', handleActive)
73
73
  eventBus.off('interact:selectionchange', handleSelectionChange)
74
- eventBus.off(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
74
+ eventBus.off(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
75
75
  }
76
76
  }, [eventBus])
77
77
 
@@ -20,9 +20,9 @@ const SVG_SEL = 'svg'
20
20
  const makeEventBus = () => {
21
21
  const listeners = {}
22
22
  return {
23
- on: jest.fn((e, fn) => { listeners[e] = fn }),
23
+ on: jest.fn((eventName, fn) => { listeners[eventName] = fn }),
24
24
  off: jest.fn(),
25
- emit: (e, payload) => listeners[e]?.(payload)
25
+ emit: (eventName, payload) => listeners[eventName]?.(payload)
26
26
  }
27
27
  }
28
28
 
@@ -170,7 +170,7 @@ describe('Markers — selection', () => {
170
170
  })
171
171
 
172
172
  it('calls resolveActive when the marker is the active listbox item', () => {
173
- const SET_ACTIVE = 'map:setactivefeature'
173
+ const SET_ACTIVE = 'map:setactiveitem'
174
174
  const sr = makeSymbolRegistry({ resolveActive: jest.fn(() => '<circle class="active"/>') })
175
175
  const { eb } = setup({ markers: [makeMarker()], symbolRegistry: sr })
176
176
  act(() => eb.emit(SET_ACTIVE, { id: MARKER_ID }))
@@ -3,7 +3,7 @@ import { stringToKebab } from '../../../utils/stringToKebab.js'
3
3
  const SymbolMarker = ({ marker, mapId, markerRef, isSelected, symbolProps }) => { // NOSONAR: project does not use PropTypes
4
4
  const { resolvedSvg, viewBox, anchor, shapeId, scaledWidth, scaledHeight } = symbolProps
5
5
  return (
6
- <svg // NOSONAR: role='img' with aria-label is the correct ARIA pattern for SVG icons; <img> is not appropriate here
6
+ <svg
7
7
  ref={markerRef(marker.id)}
8
8
  id={`${mapId}-marker-${marker.id}`}
9
9
  className={[
@@ -15,8 +15,7 @@ const SymbolMarker = ({ marker, mapId, markerRef, isSelected, symbolProps }) =>
15
15
  height={scaledHeight}
16
16
  viewBox={viewBox}
17
17
  overflow='visible'
18
- role='img'
19
- aria-label={marker.label || 'Map marker'}
18
+ aria-hidden='true'
20
19
  style={{
21
20
  display: marker.isVisible ? 'block' : 'none',
22
21
  marginLeft: `${-anchor[0] * scaledWidth}px`,
@@ -19,19 +19,18 @@ describe('SymbolMarker', () => {
19
19
  expect(container.querySelector('svg').getAttribute('id')).toBe('my-map-marker-marker-1')
20
20
  })
21
21
 
22
- it('has role img', () => {
22
+ // Decorative: the ancestor markers layer is aria-hidden, and SpatialList (the accessible
23
+ // spatial listbox) is the sole accessible surface for a marker's label — see SpatialList.jsx.
24
+ it('is aria-hidden', () => {
23
25
  const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
24
- expect(container.querySelector('svg').getAttribute('role')).toBe('img')
26
+ expect(container.querySelector('svg').getAttribute('aria-hidden')).toBe('true')
25
27
  })
26
28
 
27
- it('aria-label uses marker.label', () => {
29
+ it('has no role or aria-label, regardless of marker.label', () => {
28
30
  const { container } = render(<SymbolMarker marker={makeMarker({ label: 'My location' })} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
29
- expect(container.querySelector('svg').getAttribute('aria-label')).toBe('My location')
30
- })
31
-
32
- it('aria-label falls back to Map marker when no label', () => {
33
- const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
34
- expect(container.querySelector('svg').getAttribute('aria-label')).toBe('Map marker')
31
+ const svg = container.querySelector('svg')
32
+ expect(svg.getAttribute('role')).toBeNull()
33
+ expect(svg.getAttribute('aria-label')).toBeNull()
35
34
  })
36
35
 
37
36
  it('applies shapeId class', () => {
@@ -2,31 +2,20 @@ import React, { useRef, useEffect, useMemo } from 'react'
2
2
  import { useConfig } from '../../store/configContext'
3
3
  import { useApp } from '../../store/appContext'
4
4
  import { stringToKebab } from '../../../utils/stringToKebab.js'
5
+ import { getPanelElementId } from '../../../utils/getPanelElementId.js'
5
6
  import { useModalPanelBehaviour } from '../../hooks/useModalPanelBehaviour.js'
6
7
  import { useIsScrollable } from '../../hooks/useIsScrollable.js'
8
+ import { classifyPanel, getPanelRole } from '../../../utils/getPanelRole.js'
7
9
  import { Icon } from '../Icon/Icon'
8
10
  import { Tabs } from '../Tabs/Tabs.jsx'
9
11
 
10
12
  const computePanelState = (bpConfig, triggeringElement, focus, focusOnOpen) => {
11
- const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
12
- const isDialog = !isAside && bpConfig.dismissible
13
- const isModal = bpConfig.modal === true
14
- const isDismissible = bpConfig.dismissible !== false
13
+ const { isModal, isAside, isDismissible, isDialog } = classifyPanel(bpConfig)
15
14
  const shouldFocus = isModal || focusOnOpen === true || (focusOnOpen !== false && focus !== false && (focus === true || Boolean(triggeringElement)))
16
15
  const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
17
16
  return { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl }
18
17
  }
19
18
 
20
- const getPanelRole = (isDialog, isDismissible) => {
21
- if (isDialog) {
22
- return 'dialog'
23
- }
24
- if (isDismissible) {
25
- return 'complementary'
26
- }
27
- return 'region'
28
- }
29
-
30
19
  const buildPanelClassNames = (slot, showLabel) => [
31
20
  'im-c-panel',
32
21
  `im-c-panel--${slot}`,
@@ -38,15 +27,17 @@ const buildPanelBodyClassNames = (showLabel, isDismissible) => [
38
27
  !showLabel && isDismissible && 'im-c-panel__body--offset'
39
28
  ].filter(Boolean).join(' ')
40
29
 
41
- const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isModal, width, panelClass, slot }) => ({
30
+ const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isModal, width, panelClass, slot, isOpen }) => ({
42
31
  id: elementId,
43
32
  'aria-labelledby': `${elementId}-label`,
44
33
  tabIndex: shouldFocus ? -1 : undefined, // nosonar
45
- role: getPanelRole(isDialog, isDismissible),
34
+ role: getPanelRole({ isDialog, isDismissible }),
46
35
  'aria-modal': isDialog && isModal ? 'true' : undefined,
47
36
  style: width ? { width } : undefined,
48
37
  className: panelClass,
49
- 'data-slot': slot
38
+ 'data-slot': slot,
39
+ // Panel is mounted permanently (see mapPanels.js); hidden is what actually opens/closes it.
40
+ hidden: !isOpen
50
41
  })
51
42
 
52
43
  const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }) => ({
@@ -112,7 +103,7 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
112
103
 
113
104
  const rootEl = document.getElementById(`${id}-im-app`)
114
105
  const bpConfig = panelConfig[breakpoint]
115
- const elementId = `${id}-panel-${stringToKebab(panelId)}`
106
+ const elementId = getPanelElementId(id, panelId)
116
107
 
117
108
  const { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement, panelConfig.focus, focusOnOpen) // nosonar
118
109
 
@@ -129,7 +120,15 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
129
120
  const panelRef = rootRef || internalPanelRef
130
121
 
131
122
  const handleClose = () => {
132
- requestAnimationFrame(() => { (props?.triggeringElement || layoutRefs.viewportRef.current).focus?.({ preventScroll: interfaceType !== 'keyboard' }) })
123
+ requestAnimationFrame(() => {
124
+ // Re-checked here, not at handleClose-call time — the triggering element (a button, a
125
+ // Features-list item, ...) may have been removed from the DOM while the panel was open
126
+ // (e.g. the map panned and the feature it came from dropped out of the current list).
127
+ // document.contains() on a detached node is false, not a throw, so this is a plain check.
128
+ const triggeringElement = props?.triggeringElement
129
+ const focusTarget = triggeringElement && document.contains(triggeringElement) ? triggeringElement : layoutRefs.viewportRef.current
130
+ focusTarget?.focus?.({ preventScroll: interfaceType !== 'keyboard' })
131
+ })
133
132
  dispatch({ type: 'CLOSE_PANEL', payload: panelId })
134
133
  }
135
134
 
@@ -149,7 +148,7 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
149
148
  const panelBodyClass = buildPanelBodyClassNames(bpConfig.showLabel ?? true, isDismissible)
150
149
  const innerHtmlProp = useMemo(() => html ? { __html: html } : null, [html])
151
150
 
152
- const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot })
151
+ const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot, isOpen })
153
152
  const bodyProps = buildBodyProps({ bodyRef, panelBodyClass, isBodyScrollable, elementId })
154
153
  // DOM anchor for controls DOM-projected via the JS/consumer-HTML API — see HtmlElementHost.jsx.
155
154
  // Only present on the items-capable body below: dangerouslySetInnerHTML owns the static-html
@@ -20,6 +20,11 @@
20
20
  pointer-events: auto;
21
21
  color: var(--foreground-color);
22
22
  background-color: var(--background-color);
23
+
24
+ // Override needed: display: flex above beats the browser's default [hidden] rule otherwise.
25
+ &[hidden] {
26
+ display: none;
27
+ }
23
28
  }
24
29
 
25
30
  // 2. Elements
@@ -66,6 +71,10 @@
66
71
  // plain block child would.
67
72
  display: flex;
68
73
  flex-direction: column;
74
+
75
+ // A panel's `html` config (see Panel.jsx) commonly uses GOV.UK Frontend classes, which set
76
+ // colour explicitly rather than inheriting it — see tools/_govuk-content.scss.
77
+ @include tools.govuk-content-fallback-colors;
69
78
  }
70
79
 
71
80
  // The tabpanel (see Tabs.module.scss) is its own scroll/focus region nested inside this
@@ -102,6 +102,20 @@ describe('Panel', () => {
102
102
  expect(dialog).toHaveAttribute('aria-modal', 'true')
103
103
  expect(dialog).toHaveAttribute('tabIndex', '-1')
104
104
  })
105
+
106
+ it('renders dialog role and aria-modal for a modal panel even when dismissible is left unset (WCAG requires a modal to be dismissible)', () => {
107
+ renderPanel({ desktop: { slot: 'overlay', modal: true } })
108
+ const dialog = screen.getByRole('dialog')
109
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
110
+ expect(screen.getByRole('button', { name: 'Close Settings' })).toBeInTheDocument()
111
+ })
112
+
113
+ it('renders dialog role and aria-modal for a modal panel even when dismissible: false is set (a non-dismissible modal would trap keyboard/AT users)', () => {
114
+ renderPanel({ desktop: { slot: 'overlay', modal: true, dismissible: false } })
115
+ const dialog = screen.getByRole('dialog')
116
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
117
+ expect(screen.getByRole('button', { name: 'Close Settings' })).toBeInTheDocument()
118
+ })
105
119
  })
106
120
 
107
121
  describe('focus behaviour', () => {
@@ -142,9 +156,18 @@ describe('Panel', () => {
142
156
  })
143
157
 
144
158
  describe('close functionality', () => {
159
+ // document.contains() needs a real Node — a plain { focus: jest.fn() } mock object throws
160
+ // ("parameter 1 is not of type 'Node'"), so triggeringElement here is a real element, spied
161
+ // on and attached to the document, matching what production always passes
162
+ // (e.currentTarget / document.activeElement).
163
+ const makeAttachedTriggeringElement = () => {
164
+ const el = document.createElement('button')
165
+ document.body.appendChild(el)
166
+ return { el, focusMock: jest.spyOn(el, 'focus') }
167
+ }
168
+
145
169
  it('focuses triggeringElement on close for button slots', () => {
146
- const focusMock = jest.fn()
147
- const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
170
+ const { el: triggeringElement, focusMock } = makeAttachedTriggeringElement()
148
171
 
149
172
  renderPanel(
150
173
  { desktop: { slot: 'top-button', dismissible: true, open: false } },
@@ -157,8 +180,7 @@ describe('Panel', () => {
157
180
  })
158
181
 
159
182
  it('handles close for non-button slots', () => {
160
- const focusMock = jest.fn()
161
- const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
183
+ const { el: triggeringElement, focusMock } = makeAttachedTriggeringElement()
162
184
 
163
185
  renderPanel(
164
186
  { desktop: { slot: 'overlay', dismissible: true, modal: true } },
@@ -176,6 +198,20 @@ describe('Panel', () => {
176
198
  expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
177
199
  expect(dispatch).toHaveBeenCalledWith({ type: 'CLOSE_PANEL', payload: 'Settings' })
178
200
  })
201
+
202
+ it('falls back to viewportRef focus when triggeringElement has been removed from the DOM', () => {
203
+ const triggeringElement = document.createElement('button') // never attached
204
+ const focusMock = jest.spyOn(triggeringElement, 'focus')
205
+
206
+ renderPanel(
207
+ { desktop: { slot: 'side', dismissible: true, open: false } },
208
+ { props: { triggeringElement } }
209
+ )
210
+
211
+ fireEvent.click(screen.getByRole('button', { name: 'Close Settings' }))
212
+ expect(focusMock).not.toHaveBeenCalled()
213
+ expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
214
+ })
179
215
  })
180
216
 
181
217
  describe('content rendering', () => {
@@ -308,4 +344,33 @@ describe('Panel', () => {
308
344
  })
309
345
  })
310
346
  })
347
+
348
+ describe('isOpen (always-mounted, hidden-toggle)', () => {
349
+ it('renders hidden (not unmounted) when isOpen is false, so its id stays a real element', () => {
350
+ renderPanel({}, { isOpen: false })
351
+ const panel = document.getElementById('app-panel-settings')
352
+ expect(panel).toBeInTheDocument()
353
+ expect(panel).toHaveAttribute('hidden')
354
+ })
355
+
356
+ it('does not focus a closed panel, even one that would otherwise auto-focus (modal)', () => {
357
+ renderPanel({ desktop: { slot: 'overlay', dismissible: true, modal: true } }, { isOpen: false })
358
+ const panel = document.getElementById('app-panel-settings')
359
+ expect(document.activeElement).not.toBe(panel)
360
+ })
361
+
362
+ it('keeps the same DOM node (no swap/remount) across an isOpen transition, and un-hides it once open', () => {
363
+ const panelConfig = { desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: true } }
364
+ const { rerender } = render(<Panel panelId='Settings' panelConfig={panelConfig} label='Settings' isOpen={false} />)
365
+ const panelWhileClosed = document.getElementById('app-panel-settings')
366
+ expect(panelWhileClosed).toHaveAttribute('hidden')
367
+
368
+ rerender(<Panel panelId='Settings' panelConfig={panelConfig} label='Settings' isOpen items={[{ id: 'a', element: <p>Item</p> }]} />)
369
+
370
+ const panelWhileOpen = document.getElementById('app-panel-settings')
371
+ expect(panelWhileOpen).toBe(panelWhileClosed)
372
+ expect(panelWhileOpen).not.toHaveAttribute('hidden')
373
+ expect(screen.getByText('Item')).toBeInTheDocument()
374
+ })
375
+ })
311
376
  })