@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,7 @@
1
1
  // components/MapButton.jsx
2
2
  import React, { useState, useRef, useCallback } from 'react'
3
3
  import { stringToKebab } from '../../../utils/stringToKebab'
4
+ import { getPanelElementId } from '../../../utils/getPanelElementId.js'
4
5
  import { Tooltip } from '../Tooltip/Tooltip'
5
6
  import { Icon } from '../Icon/Icon'
6
7
  import { SlotRenderer } from '../../renderer/SlotRenderer'
@@ -88,6 +89,10 @@ const makePopupKeyUpHandler = (hasMenu, buttonRefs, buttonId, setMenuStartPos, s
88
89
  const getButtonSlot = (panelId, buttonId) =>
89
90
  panelId ? `${stringToKebab(buttonId)}-button` : undefined
90
91
 
92
+ // aria-haspopup only accepts these role-shaped tokens (plus 'true', unused here) — any other
93
+ // controlled-element role (e.g. a panel's 'complementary'/'region') means no aria-haspopup.
94
+ const HASPOPUP_ROLES = new Set(['menu', 'listbox', 'tree', 'grid', 'dialog'])
95
+
91
96
  /**
92
97
  * Determines the controlled element (panel or popup menu) for ARIA attributes.
93
98
  * @param {Object} options - Configuration options
@@ -95,14 +100,16 @@ const getButtonSlot = (panelId, buttonId) =>
95
100
  * @param {string} options.panelId - ID of the controlled panel (if applicable)
96
101
  * @param {string} options.buttonId - Unique button identifier
97
102
  * @param {boolean} options.hasMenu - Whether the button has a popup menu
98
- * @returns {Object|null} Object with id and type ('panel' or 'popup'), or null if no controlled element
103
+ * @param {string} [options.panelRole] - The controlled panel's own ARIA role (see getPanelRole.js),
104
+ * used for aria-haspopup on the button — undefined when no panel is controlled.
105
+ * @returns {Object|null} Object with id, type ('panel' or 'popup') and role, or null if no controlled element
99
106
  */
100
- const getControlledElement = ({ idPrefix, panelId, buttonId, hasMenu }) => {
107
+ const getControlledElement = ({ idPrefix, panelId, buttonId, hasMenu, panelRole }) => {
101
108
  if (panelId) {
102
- return { id: `${idPrefix}-panel-${stringToKebab(panelId)}`, type: 'panel' }
109
+ return { id: getPanelElementId(idPrefix, panelId), type: 'panel', role: panelRole }
103
110
  }
104
111
  if (hasMenu) {
105
- return { id: `${idPrefix}-popup-${stringToKebab(buttonId)}`, type: 'popup' }
112
+ return { id: `${idPrefix}-popup-${stringToKebab(buttonId)}`, type: 'popup', role: 'menu' }
106
113
  }
107
114
  return null
108
115
  }
@@ -168,7 +175,7 @@ const buildButtonProps = ({
168
175
  'aria-expanded': ariaExpanded,
169
176
  'aria-pressed': typeof isPressed === 'boolean' ? isPressed : undefined,
170
177
  'aria-controls': controlledElement?.id ?? ariaControls,
171
- 'aria-haspopup': controlledElement?.type === 'popup' ? 'menu' : undefined,
178
+ 'aria-haspopup': HASPOPUP_ROLES.has(controlledElement?.role) ? controlledElement.role : undefined,
172
179
  ...(href
173
180
  ? { href, target: '_blank', onKeyUp: handleKeyUp, role: 'button' }
174
181
  : { type: 'button' })
@@ -191,6 +198,7 @@ const buildButtonProps = ({
191
198
  * @param {boolean} [props.isExpanded] - Whether content controlled by the button is expanded
192
199
  * @param {boolean} [props.isHidden=false] - Whether to hide the button (CSS display: none)
193
200
  * @param {boolean} [props.isPanelOpen=false] - Whether the controlled panel is open
201
+ * @param {string} [props.panelRole] - The controlled panel's own ARIA role, used for aria-haspopup
194
202
  * @param {string} [props.variant] - CSS variant class for styling (e.g., 'primary')
195
203
  * @param {Function} [props.onClick] - Custom click handler
196
204
  * @param {string} [props.panelId] - ID of the panel controlled by this button
@@ -212,6 +220,7 @@ export const MapButton = ({
212
220
  isExpanded,
213
221
  isHidden,
214
222
  isPanelOpen,
223
+ panelRole,
215
224
  variant,
216
225
  onClick,
217
226
  panelId,
@@ -239,7 +248,7 @@ export const MapButton = ({
239
248
  const hasMenu = menuItems?.length >= 1
240
249
  const showIcon = iconId || iconSvgContent || hasMenu
241
250
  const buttonSlot = getButtonSlot(panelId, buttonId)
242
- const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu })
251
+ const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu, panelRole })
243
252
 
244
253
  /**
245
254
  * Handles button click events.
@@ -301,7 +310,10 @@ export const MapButton = ({
301
310
  >
302
311
  {showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
303
312
  {buttonSlot && <SlotRenderer slot={buttonSlot} />}
304
- {isPopupOpen && <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} buttonRect={menuRect} />}
313
+ {/* Mounted permanently, not just while open, so its aria-controls id stays a stable node. */}
314
+ {controlledElement?.type === 'popup' && (
315
+ <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} buttonRect={menuRect} isOpen={isPopupOpen} />
316
+ )}
305
317
  </div>
306
318
  )
307
319
  }
@@ -30,8 +30,7 @@
30
30
  justify-content: center;
31
31
  align-items: center;
32
32
 
33
- padding-left: var(--button-padding);
34
- padding-right: var(--button-padding);
33
+ padding: 0 var(--button-padding) 0 var(--button-padding);
35
34
 
36
35
  pointer-events: auto;
37
36
  color: var(--foreground-color);
@@ -23,7 +23,8 @@ jest.mock('../../renderer/SlotRenderer', () => ({
23
23
  }))
24
24
 
25
25
  jest.mock('../PopupMenu/PopupMenu', () => ({
26
- PopupMenu: ({ startPos, items, buttonRect }) => {
26
+ // Mirrors the real component: always mounted, hidden/children gated by isOpen.
27
+ PopupMenu: ({ popupMenuId, isOpen, startPos, items, buttonRect }) => {
27
28
  let selectedIndex = -1
28
29
  if (startPos === 'first' && items?.length > 0) {
29
30
  selectedIndex = 0
@@ -31,7 +32,11 @@ jest.mock('../PopupMenu/PopupMenu', () => ({
31
32
  if (startPos === 'last' && items?.length > 0) {
32
33
  selectedIndex = items.length - 1
33
34
  }
34
- return <div data-testid='popup-menu' data-start-pos={String(startPos)} data-selected-index={String(selectedIndex)} data-has-rect={String(!!buttonRect)}>{items?.map((item, i) => <div key={i} data-testid={`menu-item-${i}`}>{item.label}</div>)}</div>
35
+ return (
36
+ <div id={popupMenuId} hidden={!isOpen} data-testid='popup-menu' data-start-pos={String(startPos)} data-selected-index={String(selectedIndex)} data-has-rect={String(!!buttonRect)}>
37
+ {isOpen && items?.map((item, i) => <div key={i} data-testid={`menu-item-${i}`}>{item.label}</div>)}
38
+ </div>
39
+ )
35
40
  }
36
41
  }))
37
42
 
@@ -78,9 +83,20 @@ describe('MapButton', () => {
78
83
  expect(button).toHaveAttribute('aria-expanded', 'false')
79
84
  expect(button).not.toHaveAttribute('aria-pressed')
80
85
  expect(button).toHaveAttribute('aria-controls', 'prefix-panel-settings')
86
+ expect(button).not.toHaveAttribute('aria-haspopup')
81
87
  expect(screen.getByTestId('slot')).toHaveAttribute('data-slot', 'test-button')
82
88
  })
83
89
 
90
+ it('sets aria-haspopup="dialog" when the controlled panel is a dialog', () => {
91
+ renderButton({ panelId: 'Settings', idPrefix: 'prefix', panelRole: 'dialog' })
92
+ expect(getButton()).toHaveAttribute('aria-haspopup', 'dialog')
93
+ })
94
+
95
+ it.each(['complementary', 'region'])('sets no aria-haspopup when the controlled panel role is %s', (panelRole) => {
96
+ renderButton({ panelId: 'Settings', idPrefix: 'prefix', panelRole })
97
+ expect(getButton()).not.toHaveAttribute('aria-haspopup')
98
+ })
99
+
84
100
  it.each([
85
101
  [{ isPressed: true }, 'true'],
86
102
  [{ isPressed: 'true' }, undefined]
@@ -118,7 +134,7 @@ describe('MapButton', () => {
118
134
  renderButton({ isDisabled: true, menuItems: [{ label: 'Item' }], onClick })
119
135
  fireEvent.click(getButton())
120
136
  expect(onClick).not.toHaveBeenCalled()
121
- expect(screen.queryByTestId('popup-menu')).not.toBeInTheDocument()
137
+ expect(screen.getByTestId('popup-menu')).not.toBeVisible()
122
138
  })
123
139
 
124
140
  it('stores button refs correctly', () => {
@@ -138,6 +154,28 @@ describe('MapButton', () => {
138
154
  expect(button).toHaveAttribute('aria-haspopup', 'menu')
139
155
  })
140
156
 
157
+ it('reserves the aria-controls id as a stable, hidden node before the popup has ever been opened', () => {
158
+ renderButton({ idPrefix: 'prefix', menuItems: [{ label: 'Item' }] })
159
+ const menu = document.getElementById('prefix-popup-test')
160
+ expect(menu).toBeInTheDocument()
161
+ expect(menu).not.toBeVisible()
162
+ })
163
+
164
+ it('keeps the same DOM node (no swap/remount) when opening and closing the popup', () => {
165
+ renderButton({ idPrefix: 'prefix', menuItems: [{ label: 'Item' }] })
166
+ const menuBeforeOpen = document.getElementById('prefix-popup-test')
167
+
168
+ fireEvent.click(getButton())
169
+ const menuWhileOpen = document.getElementById('prefix-popup-test')
170
+ expect(menuWhileOpen).toBe(menuBeforeOpen)
171
+ expect(menuWhileOpen).toBeVisible()
172
+
173
+ fireEvent.click(getButton())
174
+ const menuAfterClose = document.getElementById('prefix-popup-test')
175
+ expect(menuAfterClose).toBe(menuBeforeOpen)
176
+ expect(menuAfterClose).not.toBeVisible()
177
+ })
178
+
141
179
  it('toggles popup aria-expanded and startPos', () => {
142
180
  renderButton({ menuItems: [{ label: 'Item' }] })
143
181
  const button = getButton()
@@ -224,7 +262,7 @@ describe('MapButton', () => {
224
262
  it('does nothing for unrelated keys', () => {
225
263
  renderButton({ menuItems: [{ label: 'Item' }] })
226
264
  fireEvent.keyUp(getButton(), { key: 'Enter' })
227
- expect(screen.queryByTestId('popup-menu')).not.toBeInTheDocument()
265
+ expect(screen.getByTestId('popup-menu')).not.toBeVisible()
228
266
  })
229
267
 
230
268
  it.each([
@@ -18,13 +18,13 @@ const ZOOM_ACTIONS = [
18
18
  { id: 'nudgeZoomOut', label: 'Zoom out', announceLabel: 'Zoomed out', method: 'zoomOut' }
19
19
  ]
20
20
 
21
- export const MoveControls = () => {
21
+ export const MapControls = () => {
22
22
  const { id: appId, mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta } = useConfig()
23
23
  const { dispatch, expandedButtons, nudgeStepSize, interfaceType, layoutRefs } = useApp()
24
24
  const { isAtMaxZoom, isAtMinZoom } = useMap()
25
25
  const { announce } = useService()
26
26
 
27
- const isOpen = expandedButtons.has('moveControls')
27
+ const isOpen = expandedButtons.has('mapControls')
28
28
  const firstDirectionButtonId = `${appId}-pan-up`
29
29
 
30
30
  useEffect(() => {
@@ -41,7 +41,7 @@ export const MoveControls = () => {
41
41
  // Registry buttons (declared via a plugin's manifest) auto-return focus to the map
42
42
  // viewport after a click (see createButtonClickHandler in mapButtons.js) unless
43
43
  // marked keepFocus — which is how e.g. draw's own toolbar buttons never leave
44
- // keyboard shortcuts (like arrow-key vertex nudging) stranded afterward. MoveControls
44
+ // keyboard shortcuts (like arrow-key vertex nudging) stranded afterward. MapControls
45
45
  // renders its own buttons directly, bypassing that mechanism entirely, so it has to
46
46
  // replicate the same return-to-viewport behaviour itself. But unlike a one-off
47
47
  // action button, the D-pad is meant to be pressed repeatedly — a keyboard/switch
@@ -70,7 +70,7 @@ export const MoveControls = () => {
70
70
 
71
71
  // Any plugin can claim the D-pad for something other than panning the map (e.g.
72
72
  // moving a selected draw vertex) by setting mapProvider.activeMoveTarget — a
73
- // generic { move(dx, dy, isLargeStep), label? } contract. MoveControls doesn't
73
+ // generic { move(dx, dy, isLargeStep), label? } contract. MapControls doesn't
74
74
  // know or care what's claiming it; it just checks for a claimant before
75
75
  // defaulting to its own panBy behaviour. Read fresh on every click rather than
76
76
  // subscribed to reactively, since it's a plain mapProvider property, not state.
@@ -107,7 +107,7 @@ export const MoveControls = () => {
107
107
  // first tabbing all the way back to the map. Handling arrow keys here — while
108
108
  // focus is anywhere within this control, not just on a direction button — covers
109
109
  // that directly, without needing draw's (or the app's own) keyboard-shortcut
110
- // guards to special-case MoveControls' buttons. Only arrow keys are handled;
110
+ // guards to special-case MapControls' buttons. Only arrow keys are handled;
111
111
  // other shortcuts (delete/escape/undo) still require focus on the viewport,
112
112
  // which mouse/touch users already get back automatically after any click here.
113
113
  const handleContainerKeyDown = (e) => {
@@ -120,12 +120,12 @@ export const MoveControls = () => {
120
120
  }
121
121
 
122
122
  const containerClassName = [
123
- 'im-c-move-controls',
124
- !isOpen && 'im-c-move-controls--collapsed'
123
+ 'im-c-map-controls',
124
+ !isOpen && 'im-c-map-controls--collapsed'
125
125
  ].filter(Boolean).join(' ')
126
126
 
127
127
  const directionsGroup = (
128
- <div key='directions' role='group' aria-label='Direction controls' className='im-c-move-controls__directions'>{/* NOSONAR - div with role="group" is correct for a button group */}
128
+ <div key='directions' role='group' aria-label='Direction controls' className='im-c-map-controls__directions'>{/* NOSONAR - div with role="group" is correct for a button group */}
129
129
  {DIRECTIONS.map(({ id, verb, dx, dy }) => (
130
130
  <MapButton
131
131
  key={id}
@@ -154,7 +154,7 @@ export const MoveControls = () => {
154
154
  )
155
155
 
156
156
  const zoomGroup = (
157
- <div key='zoom' role='group' aria-label='Zoom controls' className='im-c-move-controls__zoom'>{/* NOSONAR - div with role="group" is correct for a button group */}
157
+ <div key='zoom' role='group' aria-label='Zoom controls' className='im-c-map-controls__zoom'>{/* NOSONAR - div with role="group" is correct for a button group */}
158
158
  {ZOOM_ACTIONS.map(({ id, label, announceLabel, method }) => (
159
159
  <MapButton
160
160
  key={id}
@@ -169,7 +169,7 @@ export const MoveControls = () => {
169
169
  )
170
170
 
171
171
  return (
172
- <div id={`${appId}-move-controls-content`} className={containerClassName} onKeyDown={handleContainerKeyDown} data-map-keyboard-scope>{/* NOSONAR - only catches arrow-key bubbling from the real, already-focusable button descendants below; the div itself needs no role/tabIndex */}
172
+ <div id={`${appId}-map-controls-content`} className={containerClassName} onKeyDown={handleContainerKeyDown} data-map-keyboard-scope>{/* NOSONAR - only catches arrow-key bubbling from the real, already-focusable button descendants below; the div itself needs no role/tabIndex */}
173
173
  {directionsGroup}
174
174
  {zoomGroup}
175
175
  </div>
@@ -1,63 +1,63 @@
1
1
  @use '../../../scss/tools/index' as tools;
2
2
 
3
3
  // ===================================================
4
- // Component: MoveControls
4
+ // Component: MapControls
5
5
  // ===================================================
6
6
 
7
7
  // 1. Base styles
8
- .im-c-move-controls {
8
+ .im-c-map-controls {
9
9
  display: flex;
10
10
  flex-direction: column;
11
11
  align-items: center;
12
12
  gap: var(--divider-gap);
13
13
  }
14
14
 
15
- .im-c-move-controls.im-c-move-controls--collapsed {
15
+ .im-c-map-controls.im-c-map-controls--collapsed {
16
16
  display: none;
17
17
  }
18
18
 
19
19
  // 2. Elements
20
- .im-c-move-controls__directions {
20
+ .im-c-map-controls__directions {
21
21
  display: grid;
22
22
  grid-template-columns: repeat(3, var(--button-size));
23
23
  grid-template-rows: repeat(3, var(--button-size));
24
24
  }
25
25
 
26
- .im-c-move-controls__directions .im-c-button-wrapper--pan-up {
26
+ .im-c-map-controls__directions .im-c-button-wrapper--pan-up {
27
27
  grid-column: 2;
28
28
  grid-row: 1;
29
29
  }
30
30
 
31
- .im-c-move-controls__directions .im-c-button-wrapper--pan-left {
31
+ .im-c-map-controls__directions .im-c-button-wrapper--pan-left {
32
32
  grid-column: 1;
33
33
  grid-row: 2;
34
34
  }
35
35
 
36
- .im-c-move-controls__directions .im-c-button-wrapper--pan-right {
36
+ .im-c-map-controls__directions .im-c-button-wrapper--pan-right {
37
37
  grid-column: 3;
38
38
  grid-row: 2;
39
39
  }
40
40
 
41
- .im-c-move-controls__directions .im-c-button-wrapper--pan-down {
41
+ .im-c-map-controls__directions .im-c-button-wrapper--pan-down {
42
42
  grid-column: 2;
43
43
  grid-row: 3;
44
44
  }
45
45
 
46
- .im-c-move-controls__directions .im-c-button-wrapper--nudge-step-toggle {
46
+ .im-c-map-controls__directions .im-c-button-wrapper--nudge-step-toggle {
47
47
  grid-column: 2;
48
48
  grid-row: 2;
49
49
  z-index: 1;
50
50
  }
51
51
 
52
- .im-c-move-controls__directions .im-c-map-button--pan-up svg {
52
+ .im-c-map-controls__directions .im-c-map-button--pan-up svg {
53
53
  transform: rotate(180deg);
54
54
  }
55
55
 
56
- .im-c-move-controls__directions .im-c-map-button--pan-left svg {
56
+ .im-c-map-controls__directions .im-c-map-button--pan-left svg {
57
57
  transform: rotate(90deg);
58
58
  }
59
59
 
60
- .im-c-move-controls__directions .im-c-map-button--pan-right svg {
60
+ .im-c-map-controls__directions .im-c-map-button--pan-right svg {
61
61
  transform: rotate(-90deg);
62
62
  }
63
63
 
@@ -67,7 +67,7 @@
67
67
  // express a plus-shaped grid where the centre button touches all four neighbours —
68
68
  // clip-path is set directly per button instead, clipping only the side(s) where it
69
69
  // touches another button and leaving the exposed side(s) showing the shadow as normal.
70
- .im-c-move-controls__directions .im-c-button-wrapper--pan-up .im-c-map-button {
70
+ .im-c-map-controls__directions .im-c-button-wrapper--pan-up .im-c-map-button {
71
71
  border-bottom-left-radius: 0;
72
72
  border-bottom-right-radius: 0;
73
73
 
@@ -76,7 +76,7 @@
76
76
  }
77
77
  }
78
78
 
79
- .im-c-move-controls__directions .im-c-button-wrapper--pan-left .im-c-map-button {
79
+ .im-c-map-controls__directions .im-c-button-wrapper--pan-left .im-c-map-button {
80
80
  border-top-right-radius: 0;
81
81
  border-bottom-right-radius: 0;
82
82
 
@@ -85,7 +85,7 @@
85
85
  }
86
86
  }
87
87
 
88
- .im-c-move-controls__directions .im-c-button-wrapper--pan-right .im-c-map-button {
88
+ .im-c-map-controls__directions .im-c-button-wrapper--pan-right .im-c-map-button {
89
89
  border-top-left-radius: 0;
90
90
  border-bottom-left-radius: 0;
91
91
 
@@ -94,7 +94,7 @@
94
94
  }
95
95
  }
96
96
 
97
- .im-c-move-controls__directions .im-c-button-wrapper--pan-down .im-c-map-button {
97
+ .im-c-map-controls__directions .im-c-button-wrapper--pan-down .im-c-map-button {
98
98
  border-top-left-radius: 0;
99
99
  border-top-right-radius: 0;
100
100
 
@@ -103,7 +103,7 @@
103
103
  }
104
104
  }
105
105
 
106
- .im-c-move-controls__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button {
106
+ .im-c-map-controls__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button {
107
107
  border-radius: 0;
108
108
  outline: 1px solid var(--button-hover-color);
109
109
 
@@ -114,13 +114,13 @@
114
114
 
115
115
  // Same inverted "pressed" treatment already used for actions-slot toggle buttons
116
116
  // (e.g. draw's Snap toggle) — reused here so Nudge mode reads consistently active.
117
- .im-c-move-controls .im-c-map-button[aria-pressed="true"] {
117
+ .im-c-map-controls .im-c-map-button[aria-pressed="true"] {
118
118
  color: var(--pressed-button-foreground-color);
119
119
  border-color: var(--pressed-button-border-color);
120
120
  background-color: var(--pressed-button-background-color);
121
121
  }
122
122
 
123
- // The trigger lives in the right-top slot, outside .im-c-move-controls, so this is
123
+ // The trigger lives in the right-top slot, outside .im-c-map-controls, so this is
124
124
  // its own top-level rule. Same colour treatment as above, but keyed off
125
125
  // aria-expanded rather than aria-pressed — the trigger is correctly a disclosure
126
126
  // button (it reveals the control below), not a toggle button, so it doesn't carry
@@ -132,31 +132,31 @@
132
132
  // hovering the expanded trigger button was a coin-flip decided by cascade order —
133
133
  // one cssnano mergeRules reshuffle in production and hover started winning,
134
134
  // showing the hover tint instead of the pressed colours. The sibling
135
- // .im-c-move-controls .im-c-map-button[aria-pressed="true"] rule above never had
135
+ // .im-c-map-controls .im-c-map-button[aria-pressed="true"] rule above never had
136
136
  // this problem (it's already (0,3,0), safely ahead of hover); this one needed the
137
137
  // same headroom.
138
- .im-c-map-button.im-c-map-button--move-controls[aria-expanded="true"] {
138
+ .im-c-map-button.im-c-map-button--map-controls[aria-expanded="true"] {
139
139
  color: var(--pressed-button-foreground-color);
140
140
  border-color: var(--pressed-button-border-color);
141
141
  background-color: var(--pressed-button-background-color);
142
142
  }
143
143
 
144
- .im-c-move-controls__zoom {
144
+ .im-c-map-controls__zoom {
145
145
  display: flex;
146
146
  flex-direction: row;
147
147
  }
148
148
 
149
149
  // A simple two-item linear row, so the standard button-group corner/shadow treatment
150
150
  // applies directly (matches .im-o-app__top's first/last-child pattern).
151
- .im-c-move-controls__zoom .im-c-button-wrapper--nudge-zoom-in .im-c-map-button {
151
+ .im-c-map-controls__zoom .im-c-button-wrapper--nudge-zoom-in .im-c-map-button {
152
152
  @include tools.border-focus-corner-override($corners: 'left');
153
153
  }
154
154
 
155
- .im-c-move-controls__zoom .im-c-button-wrapper--nudge-zoom-out .im-c-map-button {
155
+ .im-c-map-controls__zoom .im-c-button-wrapper--nudge-zoom-out .im-c-map-button {
156
156
  @include tools.border-focus-corner-override($corners: 'right');
157
157
  }
158
158
 
159
159
  // App dark modifiers
160
- .im-o-app--dark-app .im-c-move-controls__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button {
160
+ .im-o-app--dark-app .im-c-map-controls__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button {
161
161
  outline-offset: -1px;
162
162
  }