@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
@@ -21,7 +21,8 @@ const MenuItem = ({ item, isSelected, hiddenButtons, disabledButtons, pressedBut
21
21
  </li>
22
22
  )
23
23
 
24
- export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen, buttonRect }) => {
24
+ // isOpen defaults to true so direct/standalone usage doesn't need to pass it.
25
+ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen, buttonRect, isOpen = true }) => {
25
26
  const { id } = useConfig()
26
27
  const { buttonRefs, buttonConfig, hiddenButtons, disabledButtons, pressedButtons, layoutRefs } = useApp()
27
28
  const instigatorKey = buttonId ?? instigatorId
@@ -43,7 +44,8 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
43
44
  id,
44
45
  menuRef,
45
46
  setIsOpen,
46
- buttonRect
47
+ buttonRect,
48
+ isOpen
47
49
  })
48
50
 
49
51
  return createPortal(
@@ -56,11 +58,13 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
56
58
  style={menuStyle}
57
59
  role='menu' // NOSONAR
58
60
  tabIndex='-1'
61
+ hidden={!isOpen}
59
62
  aria-labelledby={instigatorKey}
60
63
  aria-activedescendant={index >= 0 ? `${id}-${stringToKebab(items[index].id)}` : undefined}
61
64
  onKeyDown={handleMenuKeyDown}
62
65
  >
63
- {items.map((item, i) => (
66
+ {/* Items only mount once open — the <ul> above stays in the DOM either way. */}
67
+ {isOpen && items.map((item, i) => (
64
68
  <MenuItem
65
69
  key={item.id}
66
70
  item={item}
@@ -651,4 +651,75 @@ describe('PopupMenu', () => {
651
651
  window.dispatchEvent(new Event('resize'))
652
652
  expect(mockSetIsOpen).toHaveBeenCalledWith(false)
653
653
  })
654
+
655
+ describe('isOpen (always-mounted, hidden-toggle)', () => {
656
+ it('renders a hidden, empty menu when isOpen is false, without grabbing focus or item content', () => {
657
+ renderMenu({ isOpen: false })
658
+ const ul = screen.getByRole('menu', { hidden: true })
659
+ expect(ul).toHaveAttribute('hidden')
660
+ expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
661
+ expect(document.activeElement).not.toBe(ul)
662
+ })
663
+
664
+ it('keeps the same ul node across an isOpen transition (no swap/remount) and un-hides it once open', () => {
665
+ const { rerender } = renderMenu({ isOpen: false })
666
+ const ulWhileClosed = screen.getByRole('menu', { hidden: true })
667
+
668
+ rerender(
669
+ <PopupMenu
670
+ id='menu'
671
+ pluginId='plugin1'
672
+ instigatorId='instigator'
673
+ startPos='first'
674
+ menuRef={menuRef}
675
+ items={items}
676
+ setIsOpen={mockSetIsOpen}
677
+ isOpen
678
+ />
679
+ )
680
+
681
+ const ulWhileOpen = screen.getByRole('menu')
682
+ expect(ulWhileOpen).toBe(ulWhileClosed)
683
+ expect(ulWhileOpen).not.toHaveAttribute('hidden')
684
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
685
+ expect(document.activeElement).toBe(ulWhileOpen)
686
+ })
687
+
688
+ it('resets selection on every open rather than carrying over a stale index from a previous open', () => {
689
+ const { rerender } = renderMenu({ startPos: 'first', startIndex: undefined })
690
+ expectSelected('Item 1')
691
+
692
+ // Close, then reopen via a plain click — selection should reset, not carry over.
693
+ rerender(
694
+ <PopupMenu
695
+ id='menu'
696
+ pluginId='plugin1'
697
+ instigatorId='instigator'
698
+ menuRef={menuRef}
699
+ items={items}
700
+ setIsOpen={mockSetIsOpen}
701
+ isOpen={false}
702
+ />
703
+ )
704
+ rerender(
705
+ <PopupMenu
706
+ id='menu'
707
+ pluginId='plugin1'
708
+ instigatorId='instigator'
709
+ menuRef={menuRef}
710
+ items={items}
711
+ setIsOpen={mockSetIsOpen}
712
+ isOpen
713
+ />
714
+ )
715
+ expectNotSelected('Item 1')
716
+ expectNotSelected('Item 2')
717
+ })
718
+
719
+ it('defaults isOpen to true for direct/standalone usage', () => {
720
+ renderMenu()
721
+ expect(screen.getByRole('menu')).not.toHaveAttribute('hidden')
722
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
723
+ })
724
+ })
654
725
  })
@@ -275,12 +275,14 @@ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disa
275
275
  * @param {object} params.menuRef - Ref to the menu UL element.
276
276
  * @param {Function} params.setIsOpen - Callback to open/close the menu.
277
277
  * @param {DOMRect} params.buttonRect - Bounding rect of the trigger button for positioning.
278
+ * @param {boolean} [params.isOpen] - Whether the menu is currently open (PopupMenu stays
279
+ * mounted; this just toggles visibility, so setup/teardown here is keyed on isOpen, not mount).
278
280
  * @returns {{ index: number, handleMenuKeyDown: Function, handleItemClick: Function,
279
281
  * menuStyle: object, menuDirection: string, menuHAlign: string }}
280
282
  */
281
283
  export const usePopupMenu = ({
282
284
  items, hiddenButtons, startIndex, startPos, instigator, instigatorKey,
283
- buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect
285
+ buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect, isOpen
284
286
  }) => {
285
287
  const { dispatch, layoutRefs } = useApp()
286
288
  const viewportRef = layoutRefs.viewportRef
@@ -329,15 +331,14 @@ export const usePopupMenu = ({
329
331
  }
330
332
  }
331
333
 
334
+ // Keyed on isOpen (not mount) since PopupMenu stays mounted; resolveInitialIndex re-runs each
335
+ // open so selection resets instead of carrying over from a previous open.
332
336
  useEffect(() => {
333
- menuRef.current?.focus()
334
- if (startPos === 'first') {
335
- setIndex(visibleIndices[0] ?? -1)
336
- } else if (startPos === 'last') {
337
- setIndex(visibleIndices[visibleIndices.length - 1] ?? -1) // NOSONAR .length - 1 used instead of .at(-1) for wider browser support
338
- } else {
339
- // No action
337
+ if (!isOpen) {
338
+ return undefined
340
339
  }
340
+ menuRef.current?.focus()
341
+ setIndex(resolveInitialIndex(startIndex, startPos, visibleIndices))
341
342
  const handleResize = () => setIsOpen(false)
342
343
  document.addEventListener('focusin', handleOutside)
343
344
  document.addEventListener('pointerdown', handleOutside)
@@ -347,7 +348,7 @@ export const usePopupMenu = ({
347
348
  document.removeEventListener('pointerdown', handleOutside)
348
349
  window.removeEventListener('resize', handleResize)
349
350
  }
350
- }, [])
351
+ }, [isOpen])
351
352
 
352
353
  const { style: menuStyle, direction: menuDirection, halign: menuHAlign } = getMenuStyle(buttonRect)
353
354
  return { index, handleMenuKeyDown, handleItemClick, menuStyle, menuDirection, menuHAlign }
@@ -11,7 +11,7 @@ export const MapStatus = () => {
11
11
  <div // NOSONAR: div + status role is semantically correct here, <output> implies a calculation result
12
12
  ref={mapStatusRef}
13
13
  role='status'
14
- className='im-c-viewport__status'
14
+ className='im-u-visually-hidden'
15
15
  />
16
16
  )
17
17
  }
@@ -0,0 +1,47 @@
1
+ import React, { forwardRef } from 'react'
2
+ import { useConfig } from '../../store/configContext.js'
3
+
4
+ // Accessible spatial list: a listbox whose items are positioned to match wherever they are on
5
+ // the map, navigable both sequentially (Arrow/Home/End) and spatially (Alt+Arrow — see
6
+ // useSpatialListFocus.js). Content is whatever the current spatialListRegistry provider
7
+ // contributes; `label` names it for aria-label accordingly.
8
+ export const SpatialList = forwardRef(({ activeItemId, tabbableId, selectedIds = [], multiselectable = false, items = [], label = 'Map features', focusable = true, onFocus, onBlur, onSelectItem }, ref) => {
9
+ const { id } = useConfig()
10
+ const hasItems = items.length > 0
11
+ // Roving tabindex: exactly one option is a Tab stop at a time — see useSpatialListFocus.js.
12
+ const currentId = activeItemId ?? tabbableId
13
+ return (
14
+ <ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> cannot host SVG marker elements
15
+ id={`${id}-spatial-list`}
16
+ ref={ref}
17
+ role='listbox' // NOSONAR
18
+ aria-hidden={hasItems ? undefined : true}
19
+ aria-label={label}
20
+ aria-describedby={`${id}-keyboard-desc`}
21
+ aria-multiselectable={multiselectable || undefined}
22
+ className='im-c-spatial-list'
23
+ onFocus={onFocus}
24
+ onBlur={onBlur}
25
+ >
26
+ {items.map(item => (
27
+ <li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern.
28
+ // tabIndex/onClick let voice control and screen readers reach and activate each option
29
+ // directly. focusable: false (draw's vertex/midpoint items) keeps every item out of
30
+ // the Tab order — that population already has a full keyboard path via the map itself.
31
+ key={item.id} id={`${id}-spatial-list-item-${item.id}`} role='option' // NOSONAR
32
+ data-id={item.id}
33
+ tabIndex={focusable && item.id === currentId ? 0 : -1}
34
+ aria-selected={selectedIds.includes(item.id)}
35
+ // Positions the (visually clipped) option over its on-map feature, so AT overlays like
36
+ // Voice Control's "Show Numbers" number it at the right screen position.
37
+ style={item.x != null ? { left: item.x, top: item.y } : undefined}
38
+ onClick={() => onSelectItem?.(item.id)}
39
+ >
40
+ {item.label}
41
+ </li>
42
+ ))}
43
+ </ul>
44
+ )
45
+ })
46
+
47
+ SpatialList.displayName = 'SpatialList'
@@ -1,10 +1,10 @@
1
1
  @use '../../../scss/tools/index' as tools;
2
2
 
3
3
  // ===================================================
4
- // Component: Features
4
+ // Component: SpatialList
5
5
  // ===================================================
6
6
 
7
- .im-c-features {
7
+ .im-c-spatial-list {
8
8
  position: absolute;
9
9
  inset: 0;
10
10
  pointer-events: none;
@@ -16,10 +16,6 @@
16
16
  $is-inset: true
17
17
  );
18
18
 
19
- &[data-focus-visible="true"][aria-activedescendant]::after {
20
- opacity: 0;
21
- }
22
-
23
19
  [role="option"] {
24
20
  position: absolute;
25
21
  width: 1px;
@@ -27,5 +23,9 @@
27
23
  overflow: hidden;
28
24
  clip-path: inset(50%);
29
25
  white-space: nowrap;
26
+ // Roving tabindex moves real focus (and click activation) onto individual options, so they
27
+ // need pointer events re-enabled — overridden off .im-c-spatial-list's pointer-events: none,
28
+ // which otherwise lets mouse interaction pass through to the map everywhere else.
29
+ pointer-events: auto;
30
30
  }
31
31
  }
@@ -0,0 +1,285 @@
1
+ import React, { useRef } from 'react'
2
+ import { render, fireEvent, act } from '@testing-library/react'
3
+ import { SpatialList } from './SpatialList.jsx'
4
+ import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
5
+ import { useConfig } from '../../store/configContext.js'
6
+
7
+ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
8
+
9
+ const APP_ID = 'test-app'
10
+ const LISTBOX = '[role="listbox"]' // NOSONAR
11
+ const OPTION = '[role="option"]' // NOSONAR
12
+ const ARIA_SELECTED = 'aria-selected'
13
+ const ITEMS = [
14
+ { id: 'f1', label: 'Feature One' },
15
+ { id: 'f2', label: 'Feature Two' }
16
+ ]
17
+
18
+ beforeEach(() => {
19
+ useConfig.mockReturnValue({ id: APP_ID })
20
+ })
21
+
22
+ // ─── SpatialList — rendering ─────────────────────────────────────────────────────
23
+
24
+ describe('SpatialList — rendering', () => {
25
+ it('renders a listbox with the correct id', () => {
26
+ const { container } = render(<SpatialList />)
27
+ expect(container.querySelector(`#${APP_ID}-spatial-list`)).toBeTruthy()
28
+ expect(container.querySelector(LISTBOX)).toBeTruthy() // NOSONAR
29
+ })
30
+
31
+ it('renders no options when items is empty', () => {
32
+ const { container } = render(<SpatialList />)
33
+ expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) // NOSONAR
34
+ })
35
+
36
+ it('defaults aria-label to "Map features" when no label prop is given', () => {
37
+ const { container } = render(<SpatialList />)
38
+ expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Map features')
39
+ })
40
+
41
+ it('uses whatever label prop is given, e.g. for draw\'s edit-mode vertices', () => {
42
+ const { container } = render(<SpatialList label='Shape points' />)
43
+ expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Shape points')
44
+ })
45
+
46
+ it('renders one option per item with correct id, data-id and label', () => {
47
+ const { container } = render(<SpatialList items={ITEMS} />)
48
+ const options = container.querySelectorAll(OPTION)
49
+ expect(options).toHaveLength(2)
50
+ expect(options[0].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f1`)
51
+ expect(options[0].dataset.id).toBe('f1')
52
+ expect(options[0].textContent).toBe('Feature One')
53
+ expect(options[1].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f2`)
54
+ expect(options[1].dataset.id).toBe('f2')
55
+ expect(options[1].textContent).toBe('Feature Two')
56
+ })
57
+
58
+ it('positions an option at its x/y when provided, for coordinate-based AT overlays (e.g. Voice Control)', () => {
59
+ const itemsWithPosition = [{ id: 'f1', label: 'Feature One', x: 120, y: 340 }]
60
+ const { container } = render(<SpatialList items={itemsWithPosition} />)
61
+ const option = container.querySelector(OPTION)
62
+ expect(option.style.left).toBe('120px')
63
+ expect(option.style.top).toBe('340px')
64
+ })
65
+
66
+ it('does not set left/top when an item has no x/y', () => {
67
+ const { container } = render(<SpatialList items={ITEMS} />)
68
+ const option = container.querySelector(OPTION)
69
+ expect(option.style.left).toBe('')
70
+ expect(option.style.top).toBe('')
71
+ })
72
+
73
+ it('sets aria-selected on items present in selectedIds', () => {
74
+ const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1']} />)
75
+ const options = container.querySelectorAll(OPTION)
76
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
77
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
78
+ })
79
+
80
+ it('sets aria-selected on multiple items when selectedIds has multiple entries', () => {
81
+ const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1', 'f2']} />)
82
+ const options = container.querySelectorAll(OPTION)
83
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
84
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true')
85
+ })
86
+
87
+ it('does not set aria-selected from activeItemId alone', () => {
88
+ const { container } = render(<SpatialList items={ITEMS} activeItemId='f1' />)
89
+ const options = container.querySelectorAll(OPTION)
90
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
91
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
92
+ })
93
+
94
+ it('gives the active item tabIndex 0 and every other item tabIndex -1 (roving tabindex)', () => {
95
+ const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' />)
96
+ const options = container.querySelectorAll(OPTION)
97
+ expect(options[0].getAttribute('tabIndex')).toBe('-1')
98
+ expect(options[1].getAttribute('tabIndex')).toBe('0')
99
+ })
100
+
101
+ it('falls back to tabbableId for roving tabindex when activeItemId is absent', () => {
102
+ const { container } = render(<SpatialList items={ITEMS} tabbableId='f1' />)
103
+ const options = container.querySelectorAll(OPTION)
104
+ expect(options[0].getAttribute('tabIndex')).toBe('0')
105
+ expect(options[1].getAttribute('tabIndex')).toBe('-1')
106
+ })
107
+
108
+ it('gives every item tabIndex -1 when focusable is false, even the active one — never a real Tab stop', () => {
109
+ const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' focusable={false} />)
110
+ const options = container.querySelectorAll(OPTION)
111
+ expect(options[0].getAttribute('tabIndex')).toBe('-1')
112
+ expect(options[1].getAttribute('tabIndex')).toBe('-1')
113
+ })
114
+
115
+ it('sets aria-multiselectable when multiselectable is true', () => {
116
+ const { container } = render(<SpatialList items={ITEMS} multiselectable />)
117
+ expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBe('true') // NOSONAR
118
+ })
119
+
120
+ it('omits aria-multiselectable when multiselectable is false', () => {
121
+ const { container } = render(<SpatialList items={ITEMS} />)
122
+ expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBeNull() // NOSONAR
123
+ })
124
+
125
+ it('is not aria-hidden when items are present', () => {
126
+ const { container } = render(<SpatialList items={ITEMS} />)
127
+ const ul = container.querySelector(LISTBOX) // NOSONAR
128
+ expect(ul.getAttribute('aria-hidden')).toBeNull()
129
+ })
130
+
131
+ it('is aria-hidden and has no options when items is empty', () => {
132
+ const { container } = render(<SpatialList />)
133
+ const ul = container.querySelector(LISTBOX) // NOSONAR
134
+ expect(ul.getAttribute('aria-hidden')).toBe('true')
135
+ expect(container.querySelectorAll(OPTION)).toHaveLength(0) // NOSONAR
136
+ })
137
+
138
+ it('sets aria-describedby to the shared hints container id', () => {
139
+ const { container } = render(<SpatialList />)
140
+ expect(container.querySelector(LISTBOX).getAttribute('aria-describedby')).toBe(`${APP_ID}-keyboard-desc`) // NOSONAR
141
+ })
142
+ })
143
+
144
+ // ─── SpatialList — interactions ──────────────────────────────────────────────────
145
+
146
+ describe('SpatialList — interactions', () => {
147
+ it('calls onFocus when the listbox receives focus', () => {
148
+ const onFocus = jest.fn()
149
+ const { container } = render(<SpatialList onFocus={onFocus} />)
150
+ fireEvent.focus(container.querySelector(LISTBOX)) // NOSONAR
151
+ expect(onFocus).toHaveBeenCalled()
152
+ })
153
+
154
+ it('calls onBlur when the listbox loses focus', () => {
155
+ const onBlur = jest.fn()
156
+ const { container } = render(<SpatialList onBlur={onBlur} />)
157
+ fireEvent.blur(container.querySelector(LISTBOX)) // NOSONAR
158
+ expect(onBlur).toHaveBeenCalled()
159
+ })
160
+
161
+ it('calls onSelectItem with the clicked item id', () => {
162
+ const onSelectItem = jest.fn()
163
+ const { container } = render(<SpatialList items={ITEMS} onSelectItem={onSelectItem} />)
164
+ fireEvent.click(container.querySelectorAll(OPTION)[1]) // NOSONAR
165
+ expect(onSelectItem).toHaveBeenCalledWith('f2')
166
+ })
167
+
168
+ it('does not throw on click when onSelectItem is not provided', () => {
169
+ const { container } = render(<SpatialList items={ITEMS} />)
170
+ expect(() => fireEvent.click(container.querySelectorAll(OPTION)[0])).not.toThrow() // NOSONAR
171
+ })
172
+ })
173
+
174
+ // ─── SpatialList + useSpatialListFocus — roving tabindex wired together for real ────
175
+ //
176
+ // The tests above exercise SpatialList in isolation (mocked onFocus/onBlur), and
177
+ // useSpatialListFocus.test.js exercises the hook in isolation (onFocus/onBlur called
178
+ // directly, not via real DOM events). Neither can catch a bug where moving real focus
179
+ // between sibling options — which roving tabindex does on every arrow key — fires a
180
+ // genuine native focusin that bubbles up and re-triggers React's onFocus on the <ul>.
181
+ // Only rendering both together with real (unmocked) focus exercises that bubbling path.
182
+
183
+ const RovingHarness = ({ eventBus }) => {
184
+ const viewportRef = useRef(document.createElement('div'))
185
+ const spatialListRef = useRef(null)
186
+ const { activeItemId, tabbableId, selectedIds, onFocus, onBlur, selectItem } = useSpatialListFocus({
187
+ viewportRef, spatialListRef, items: ITEMS, eventBus, hints: { subscribe: () => () => {}, dismiss: () => {} }
188
+ })
189
+ return (
190
+ <SpatialList
191
+ ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds}
192
+ items={ITEMS} onFocus={onFocus} onBlur={onBlur} onSelectItem={selectItem}
193
+ />
194
+ )
195
+ }
196
+
197
+ describe('SpatialList + useSpatialListFocus — roving tabindex real focus events', () => {
198
+ it('emits map:setactivefeature exactly once when arrowing to the next option', () => {
199
+ const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
200
+ const { container } = render(<RovingHarness eventBus={eb} />)
201
+ act(() => container.querySelector('[data-id="f1"]').focus())
202
+ eb.emit.mockClear()
203
+ fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
204
+ expect(eb.emit).toHaveBeenCalledTimes(1)
205
+ expect(eb.emit).toHaveBeenCalledWith('map:setactiveitem', { id: 'f2' })
206
+ })
207
+
208
+ it('moves real DOM focus to the next option on ArrowDown', () => {
209
+ const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
210
+ const { container } = render(<RovingHarness eventBus={eb} />)
211
+ act(() => container.querySelector('[data-id="f1"]').focus())
212
+ fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
213
+ expect(document.activeElement.dataset.id).toBe('f2')
214
+ })
215
+
216
+ // A real pub/sub bus, with a minimal fake "interact plugin" listening on it — mirroring what
217
+ // useSpatialList.js actually does: track the last map:setactivefeature id, and on
218
+ // map:selectfeature, mark THAT id selected and fire interact:selectionchange back. This is
219
+ // the closest reproduction of the real reported bug: Enter only ever selecting the first item
220
+ // because every arrow move was clobbering the active id back to a stale value.
221
+ const makeFakeInteractBus = () => {
222
+ const listeners = {}
223
+ let lastActiveId = null
224
+ const bus = {
225
+ on: (event, fn) => { (listeners[event] ??= []).push(fn) },
226
+ off: (event, fn) => { listeners[event] = (listeners[event] ?? []).filter(f => f !== fn) },
227
+ emit: (event, payload) => { (listeners[event] ?? []).forEach(fn => fn(payload)) }
228
+ }
229
+ bus.on('map:setactiveitem', ({ id }) => { lastActiveId = id })
230
+ bus.on('map:selectitem', () => {
231
+ bus.emit('interact:selectionchange', { selectedMarkers: lastActiveId ? [lastActiveId] : [] })
232
+ })
233
+ return bus
234
+ }
235
+
236
+ it('selects the item that is actually focused, not the first item, after navigating and pressing Enter', () => {
237
+ const eb = makeFakeInteractBus()
238
+ const { container } = render(<RovingHarness eventBus={eb} />)
239
+ act(() => container.querySelector('[data-id="f1"]').focus())
240
+ const listbox = container.querySelector(LISTBOX)
241
+ fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // NOSONAR — moves to f2
242
+ fireEvent.keyDown(listbox, { key: 'Enter' })
243
+ const options = container.querySelectorAll(OPTION)
244
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false') // f1 — must not be the one selected
245
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true') // f2 — the one actually focused
246
+ })
247
+
248
+ it('keeps navigating correctly after a selection (does not get stuck reverting to the first item)', () => {
249
+ const eb = makeFakeInteractBus()
250
+ const { container } = render(<RovingHarness eventBus={eb} />)
251
+ act(() => container.querySelector('[data-id="f1"]').focus())
252
+ const listbox = container.querySelector(LISTBOX)
253
+ fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // NOSONAR — f1 -> f2
254
+ fireEvent.keyDown(listbox, { key: 'Enter' }) // select f2
255
+ fireEvent.keyDown(listbox, { key: 'ArrowUp' }) // f2 -> f1
256
+ expect(document.activeElement.dataset.id).toBe('f1')
257
+ expect(container.querySelectorAll(OPTION)[0].tabIndex).toBe(0)
258
+ })
259
+
260
+ it('selecting a marker on the map does not move the roving tabindex position — only aria-selected changes', () => {
261
+ const eb = makeFakeInteractBus()
262
+ const { container } = render(<RovingHarness eventBus={eb} />)
263
+ // Simulates repeatedly clicking a single marker on the map — never touches the list at all.
264
+ act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f1'] }) })
265
+ act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f2'] }) })
266
+ const options = container.querySelectorAll(OPTION)
267
+ expect(options[0].tabIndex).toBe(0) // f1 — the structural first item, unmoved
268
+ expect(options[1].tabIndex).toBe(-1)
269
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
270
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true') // only this changed
271
+ })
272
+
273
+ it('moves real focus to match the selected item when Tab lands somewhere else', () => {
274
+ const eb = makeFakeInteractBus()
275
+ const { container } = render(<RovingHarness eventBus={eb} />)
276
+ // f2 selected via a map click before any keyboard interaction — tabbableId stays on the
277
+ // structural first item (f1), so Tab lands there first.
278
+ act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f2'] }) })
279
+ expect(container.querySelectorAll(OPTION)[0].tabIndex).toBe(0) // Tab will land on f1
280
+ act(() => container.querySelector('[data-id="f1"]').focus()) // simulates the Tab press
281
+ // onFocus resolves to the selected item (f2) and must move real focus there to match —
282
+ // otherwise the list's own focus/announcement would disagree with what the map highlights.
283
+ expect(document.activeElement.dataset.id).toBe('f2')
284
+ })
285
+ })
@@ -1,7 +1,8 @@
1
1
  import React, { useRef, useEffect } from 'react'
2
- import { useFeatureFocus } from '../../hooks/useFeatureFocus.js'
3
- import { useFeatureItems } from '../../hooks/useFeatureItems.js'
2
+ import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
3
+ import { useSpatialListItems } from '../../hooks/useSpatialListItems.js'
4
4
  import { EVENTS as events } from '../../../config/events.js'
5
+ import { scaleFactor } from '../../../config/appConfig.js'
5
6
  import { useConfig } from '../../store/configContext.js'
6
7
  import { useApp } from '../../store/appContext.js'
7
8
  import { useMap } from '../../store/mapContext.js'
@@ -12,7 +13,7 @@ import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts.js'
12
13
  import { useMapEvents } from '../../hooks/useMapEvents.js'
13
14
  import { MapStatus } from './MapStatus.jsx'
14
15
  import { CrossHair } from '../CrossHair/CrossHair'
15
- import { Features } from './Features'
16
+ import { SpatialList } from './SpatialList'
16
17
  import { Markers } from '../Markers/Markers'
17
18
 
18
19
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
@@ -20,14 +21,23 @@ import { Markers } from '../Markers/Markers'
20
21
  export const Viewport = () => {
21
22
  const { id, mapProvider, mapLabel, keyboardHintText, focusOnMount } = useConfig()
22
23
  const { mode, previousMode, layoutRefs, safeZoneInset, dispatch } = useApp()
23
- const { mapSize } = useMap()
24
+ const { mapSize, isMapReady } = useMap()
24
25
  const { eventBus, hints } = useService()
25
26
 
26
27
  const mapContainerRef = useRef(null)
27
- const featuresRef = useRef(null)
28
+ const spatialListRef = useRef(null)
28
29
 
29
- const { items: featureItems, multiselectable } = useFeatureItems(eventBus)
30
- const { activeFeatureId, selectedIds, onFocus: handleFeaturesFocus, onBlur: handleFeaturesBlur } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems, eventBus, hints })
30
+ const { items: spatialListItems, multiselectable, label: spatialListLabel, focusable: spatialListFocusable } = useSpatialListItems(eventBus)
31
+ // Same reference point as Alt+Enter's highlightLabelAtCenter lets the listbox's entry
32
+ // fallback prefer whichever item is nearest the middle of the view. Guarded on isMapReady:
33
+ // the map engine doesn't exist yet on Viewport's first render, so mapToScreen()/getCenter()
34
+ // would throw; null is a safe, already-handled value (falls back to the first item).
35
+ let centerScreenPoint = null
36
+ if (isMapReady) {
37
+ const centerPixel = mapProvider.mapToScreen(mapProvider.getCenter())
38
+ centerScreenPoint = { x: centerPixel.x * scaleFactor[mapSize], y: centerPixel.y * scaleFactor[mapSize] }
39
+ }
40
+ const { activeItemId, tabbableId, selectedIds, onFocus: handleSpatialListFocus, onBlur: handleSpatialListBlur, selectItem } = useSpatialListFocus({ viewportRef: layoutRefs.viewportRef, spatialListRef, items: spatialListItems, eventBus, hints, centerScreenPoint })
31
41
 
32
42
  useEffect(() => {
33
43
  const handler = () => dispatch({ type: 'SET_LISTBOX_ACTIVE' })
@@ -35,8 +45,8 @@ export const Viewport = () => {
35
45
  return () => eventBus.off('interact:listboxcapable', handler)
36
46
  }, [eventBus])
37
47
 
38
- const onFeaturesFocus = () => { handleFeaturesFocus(); hints.show(keyboardHintText, { duration: 0 }) }
39
- const onFeaturesBlur = () => { handleFeaturesBlur(); hints.dismiss() }
48
+ const onSpatialListFocus = () => { handleSpatialListFocus(); hints.show(keyboardHintText, { duration: 0 }) }
49
+ const onSpatialListBlur = () => { handleSpatialListBlur(); hints.dismiss() }
40
50
 
41
51
  useKeyboardShortcuts(layoutRefs.viewportRef)
42
52
 
@@ -87,14 +97,14 @@ export const Viewport = () => {
87
97
  >
88
98
  <div className='im-c-viewport__map-container' ref={mapContainerRef} aria-hidden='true' />
89
99
  <MapStatus />
90
- <div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef} aria-hidden='true'>
100
+ <div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef}>
91
101
  <CrossHair />
92
102
  </div>
93
103
  <div className='im-c-viewport__markers' aria-hidden='true'>
94
104
  <Markers />
95
105
  </div>
96
106
  </div>
97
- <Features ref={featuresRef} activeFeatureId={activeFeatureId} selectedIds={selectedIds} multiselectable={multiselectable} items={featureItems} onFocus={onFeaturesFocus} onBlur={onFeaturesBlur} />
107
+ <SpatialList ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds} multiselectable={multiselectable} items={spatialListItems} label={spatialListLabel} focusable={spatialListFocusable} onFocus={onSpatialListFocus} onBlur={onSpatialListBlur} onSelectItem={selectItem} />
98
108
  </>
99
109
  )
100
110
  }
@@ -21,7 +21,13 @@ jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid
21
21
 
22
22
  const KEYBOARD_HINT_TEXT = 'Test keyboad hint text'
23
23
 
24
- const mockMapProvider = { initMap: jest.fn(), updateMap: jest.fn(), clearHighlightedLabel: jest.fn() }
24
+ const mockMapProvider = {
25
+ initMap: jest.fn(),
26
+ updateMap: jest.fn(),
27
+ clearHighlightedLabel: jest.fn(),
28
+ getCenter: jest.fn(() => [0, 0]),
29
+ mapToScreen: jest.fn(() => ({ x: 100, y: 100 }))
30
+ }
25
31
 
26
32
  function setupHookMocks (mainEl, viewportEl) {
27
33
  useConfig.mockReturnValue({
@@ -38,7 +44,7 @@ function setupHookMocks (mainEl, viewportEl) {
38
44
  safeZoneInset: {},
39
45
  dispatch: jest.fn()
40
46
  })
41
- useMap.mockReturnValue({ mapSize: 'medium', dispatch: jest.fn() })
47
+ useMap.mockReturnValue({ mapSize: 'medium', isMapReady: true, dispatch: jest.fn() })
42
48
  useService.mockReturnValue({
43
49
  announce: jest.fn(),
44
50
  hints: { show: jest.fn(), dismiss: jest.fn(), subscribe: jest.fn(() => jest.fn()) },
@@ -89,6 +95,16 @@ describe('Viewport rendering', () => {
89
95
  expect(markers).toBeInTheDocument()
90
96
  })
91
97
 
98
+ // Regression: Viewport (and MapController, which kicks off map creation) render before the
99
+ // underlying map engine actually exists — mapProvider.getCenter()/mapToScreen() throw until
100
+ // then, so the spatial listbox's "nearest to center" entry point must not call them yet.
101
+ it('does not throw, and skips mapProvider.getCenter/mapToScreen, before the map is ready', () => {
102
+ useMap.mockReturnValue({ mapSize: 'medium', isMapReady: false, dispatch: jest.fn() })
103
+ expect(() => renderViewport()).not.toThrow()
104
+ expect(mockMapProvider.getCenter).not.toHaveBeenCalled()
105
+ expect(mockMapProvider.mapToScreen).not.toHaveBeenCalled()
106
+ })
107
+
92
108
  it('renders viewport with correct id and class based on mapSize', () => {
93
109
  const { viewport } = renderViewport()
94
110
  expect(viewport.id).toBe('test-map-viewport')