@defra/interactive-map 0.0.43-fmp-experimental → 0.0.44-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 (275) hide show
  1. package/assets/templates/add-polygons.njk +5 -1
  2. package/assets/templates/add-symbols.njk +5 -1
  3. package/dist/css/index.css +1 -1
  4. package/dist/esm/im-core.js +1 -1
  5. package/dist/esm/im-shell.js +1 -1
  6. package/dist/umd/im-core.js +1 -1
  7. package/dist/umd/index.js +1 -1
  8. package/docs/api/context.md +24 -0
  9. package/docs/api/keyboard-shortcut-definition.md +102 -0
  10. package/docs/api.md +18 -8
  11. package/docs/building-a-plugin.md +1 -0
  12. package/docs/plugins/draw.md +2 -2
  13. package/docs/plugins/plugin-manifest.md +9 -0
  14. package/package.json +4 -2
  15. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  16. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -0
  17. package/plugins/beta/draw-es/src/events.js +17 -8
  18. package/plugins/beta/draw-es/src/reducer.js +8 -1
  19. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  20. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ol/dist/css/index.css +1 -13
  22. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  23. package/plugins/beta/frame/dist/css/index.css +1 -11
  24. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  25. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  26. package/plugins/beta/frame/src/Frame.jsx +28 -2
  27. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  28. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  29. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  30. package/plugins/beta/scale-bar/dist/css/index.css +1 -8
  31. package/plugins/datasets/dist/css/5648.index.css +1 -5
  32. package/plugins/datasets/dist/css/index.css +1 -1
  33. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  34. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  35. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  36. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  37. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  38. package/plugins/datasets/src/registry/dataset.js +8 -12
  39. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  40. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  41. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  42. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  43. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  44. package/plugins/draw/dist/esm/index.js +1 -1
  45. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  46. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  47. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  48. package/plugins/draw/dist/umd/index.js +1 -1
  49. package/plugins/draw/src/DrawInit.jsx +41 -26
  50. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  51. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  52. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  53. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  54. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  55. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +23 -2
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +53 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  63. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  64. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  65. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  66. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  67. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  68. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  69. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  84. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  85. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  86. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  87. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  88. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  89. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  90. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +40 -17
  91. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  92. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  93. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  94. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  95. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  96. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  97. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  98. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  99. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  100. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +40 -54
  101. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +32 -13
  102. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  103. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  104. package/plugins/draw/src/events.js +4 -4
  105. package/plugins/draw/src/events.test.js +1 -1
  106. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  107. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  108. package/plugins/draw/src/manifest.js +4 -1
  109. package/plugins/draw/src/manifest.test.js +19 -7
  110. package/plugins/draw/src/utils/spatial.js +1 -31
  111. package/plugins/draw/src/utils/spatial.test.js +1 -24
  112. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  113. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  114. package/plugins/interact/dist/umd/index.js +1 -1
  115. package/plugins/interact/src/InteractInit.jsx +2 -2
  116. package/plugins/interact/src/InteractInit.test.js +3 -3
  117. package/plugins/interact/src/events.js +11 -2
  118. package/plugins/interact/src/events.test.js +32 -0
  119. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  120. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  121. package/plugins/interact/src/hooks/useCrossHairVisibility.js +9 -4
  122. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  123. package/plugins/interact/src/hooks/useSpatialList.js +251 -0
  124. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +216 -70
  125. package/plugins/interact/src/manifest.js +12 -1
  126. package/plugins/interact/src/manifest.test.js +20 -0
  127. package/plugins/interact/src/utils/spatial.js +24 -1
  128. package/plugins/interact/src/utils/spatial.test.js +26 -1
  129. package/plugins/map-key/dist/css/index.css +1 -106
  130. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  131. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  132. package/plugins/map-key/dist/umd/index.js +1 -1
  133. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  134. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  135. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  136. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  137. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  138. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  139. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  140. package/plugins/map-key/src/manifest.js +1 -1
  141. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  142. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  143. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  144. package/plugins/menu/dist/css/index.css +1 -80
  145. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  146. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  147. package/plugins/menu/dist/umd/index.js +1 -1
  148. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  149. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  150. package/plugins/menu/src/initialise/createMenu.js +3 -1
  151. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  152. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  153. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  154. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  155. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  156. package/plugins/menu/src/reducers/pluginState.js +1 -6
  157. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  158. package/plugins/search/dist/css/index.css +1 -1
  159. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  160. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  161. package/plugins/search/src/manifest.js +5 -9
  162. package/plugins/search/src/manifest.test.js +5 -0
  163. package/providers/beta/esri/dist/css/index.css +1 -34
  164. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  165. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  166. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  167. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  168. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  169. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  170. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  171. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  172. package/providers/maplibre/dist/umd/index.js +1 -1
  173. package/providers/maplibre/src/defaults.test.js +26 -0
  174. package/providers/maplibre/src/utils/labels.js +2 -2
  175. package/providers/maplibre/src/utils/labels.test.js +5 -5
  176. package/providers/maplibre/src/utils/spatial.js +0 -57
  177. package/providers/maplibre/src/utils/spatial.test.js +0 -27
  178. package/rollup.esm.mjs +2 -1
  179. package/scripts/publish-package.sh +36 -0
  180. package/src/App/App.jsx +1 -1
  181. package/src/App/App.test.jsx +6 -1
  182. package/src/App/components/CrossHair/CrossHair.jsx +25 -11
  183. package/src/App/components/CrossHair/CrossHair.module.scss +37 -12
  184. package/src/App/components/CrossHair/CrossHair.test.jsx +40 -11
  185. package/src/App/components/Hints/Hints.jsx +3 -3
  186. package/src/App/components/Hints/Hints.test.jsx +3 -3
  187. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  188. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  189. package/src/App/components/MapButton/MapButton.jsx +19 -7
  190. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  191. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  192. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  193. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  194. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  195. package/src/App/components/Markers/Markers.jsx +2 -2
  196. package/src/App/components/Markers/Markers.test.jsx +3 -3
  197. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  198. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  199. package/src/App/components/Panel/Panel.jsx +19 -20
  200. package/src/App/components/Panel/Panel.module.scss +5 -0
  201. package/src/App/components/Panel/Panel.test.jsx +69 -4
  202. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  203. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  204. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  205. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  206. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  207. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +6 -6
  208. package/src/App/components/Viewport/SpatialList.test.jsx +285 -0
  209. package/src/App/components/Viewport/Viewport.jsx +21 -11
  210. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  211. package/src/App/controls/keyboardMappings.js +7 -2
  212. package/src/App/controls/keyboardMappings.test.js +21 -0
  213. package/src/App/controls/keyboardShortcuts.js +16 -2
  214. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  215. package/src/App/hooks/useCrossHairAPI.js +29 -1
  216. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  217. package/src/App/hooks/useHintsAPI.js +5 -5
  218. package/src/App/hooks/useInterfaceAPI.js +2 -1
  219. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  220. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  221. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  222. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  223. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  224. package/src/App/hooks/useSpatialListFocus.js +296 -0
  225. package/src/App/hooks/useSpatialListFocus.test.js +957 -0
  226. package/src/App/hooks/useSpatialListItems.js +51 -0
  227. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  228. package/src/App/initialiseApp.js +6 -3
  229. package/src/App/initialiseApp.test.js +11 -0
  230. package/src/App/layout/Layout.jsx +4 -2
  231. package/src/App/layout/Layout.test.jsx +21 -0
  232. package/src/App/layout/layout.module.scss +7 -1
  233. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  234. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  235. package/src/App/registry/pluginRegistry.js +13 -0
  236. package/src/App/registry/pluginRegistry.test.js +15 -0
  237. package/src/App/registry/spatialListRegistry.js +104 -0
  238. package/src/App/registry/spatialListRegistry.test.js +218 -0
  239. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  240. package/src/App/renderer/mapButtons.js +4 -0
  241. package/src/App/renderer/mapButtons.test.js +20 -0
  242. package/src/App/renderer/mapPanels.js +33 -53
  243. package/src/App/renderer/mapPanels.test.js +29 -4
  244. package/src/App/renderer/slotHelpers.js +33 -0
  245. package/src/App/renderer/slotHelpers.test.js +79 -2
  246. package/src/App/store/AppProvider.jsx +3 -2
  247. package/src/App/store/ServiceProvider.jsx +5 -3
  248. package/src/App/store/ServiceProvider.test.jsx +20 -1
  249. package/src/InteractiveMap/InteractiveMap.js +13 -2
  250. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  251. package/src/config/appConfig.js +16 -16
  252. package/src/config/appConfig.test.js +19 -19
  253. package/src/config/defaults.js +2 -2
  254. package/src/config/events.js +8 -8
  255. package/src/scss/main.scss +2 -2
  256. package/src/test-utils.js +8 -0
  257. package/src/types.js +51 -9
  258. package/src/utils/findNearestItemInDirection.js +29 -0
  259. package/src/utils/findNearestItemInDirection.test.js +67 -0
  260. package/src/utils/getPanelElementId.js +4 -0
  261. package/src/utils/getPanelElementId.test.js +11 -0
  262. package/src/utils/getPanelRole.js +22 -0
  263. package/src/utils/getPanelRole.test.js +56 -0
  264. package/src/utils/globalAltShortcuts.js +12 -0
  265. package/src/utils/globalAltShortcuts.test.js +27 -0
  266. package/src/utils/spatialNavigate.js +49 -0
  267. package/src/utils/spatialNavigate.test.js +42 -0
  268. package/webpack.umd.mjs +6 -1
  269. package/plugins/interact/src/hooks/useMapItemList.js +0 -187
  270. package/src/App/components/Viewport/Features.jsx +0 -35
  271. package/src/App/components/Viewport/Features.test.jsx +0 -124
  272. package/src/App/hooks/useFeatureFocus.js +0 -199
  273. package/src/App/hooks/useFeatureFocus.test.js +0 -635
  274. package/src/App/hooks/useFeatureItems.js +0 -39
  275. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -1,20 +1,28 @@
1
1
  import { renderHook, act } from '@testing-library/react'
2
- import { useMapItemList } from './useMapItemList.js'
2
+ import { useSpatialList } from './useSpatialList.js'
3
+ import { createSpatialListRegistry } from '../../../../src/App/registry/spatialListRegistry.js'
3
4
 
4
5
  const MARKER_LABEL = 'Marker One'
5
6
 
6
7
  const MOVE_END = 'map:moveend'
7
8
  const DATA_CHANGE = 'map:datachange'
8
- const SET_FEATURES = 'map:setfeatures'
9
- const SET_ACTIVE = 'map:setactivefeature'
10
- const CONFIRM = 'map:selectfeature'
9
+ const SET_FEATURES = 'map:setspatiallist'
10
+ const SET_ACTIVE = 'map:setactiveitem'
11
+ const CONFIRM = 'map:selectitem'
12
+
13
+ // Items now carry more than id/label/x/y (isMarker, and for features: featureId/layerId/
14
+ // idProperty/geometry/properties) — see useSpatialList.js's own comment on why. These two
15
+ // helpers assert only the fields each test actually cares about, rather than retyping the full
16
+ // object everywhere a marker/feature item shows up.
17
+ const markerItem = (overrides) => expect.objectContaining({ isMarker: true, ...overrides })
18
+ const featureItem = (overrides) => expect.objectContaining({ isMarker: false, ...overrides })
11
19
 
12
20
  const makeEventBus = () => {
13
21
  const listeners = {}
14
22
  return {
15
- on: jest.fn((e, fn) => { listeners[e] = fn }),
23
+ on: jest.fn((eventName, fn) => { listeners[eventName] = fn }),
16
24
  off: jest.fn(),
17
- emit: jest.fn((e, payload) => listeners[e]?.(payload))
25
+ emit: jest.fn((eventName, payload) => listeners[eventName]?.(payload))
18
26
  }
19
27
  }
20
28
 
@@ -42,30 +50,78 @@ const makeMarkers = (overrides = []) => {
42
50
  }
43
51
 
44
52
  const makeMapProvider = (features = []) => ({
45
- getVisibleFeatures: jest.fn(() => features)
53
+ getVisibleFeatures: jest.fn(() => features),
54
+ mapToScreen: jest.fn(() => ({ x: 10, y: 20 }))
46
55
  })
47
56
 
48
- const setup = ({ interactionModes = [], markers, layers = [], mapProvider, eventBus, dispatch, multiSelect = false } = {}) => {
57
+ const setup = ({ interactionModes = [], markers, layers = [], mapProvider, eventBus, dispatch, multiSelect = false, mapSize = 'small' } = {}) => {
49
58
  const eb = eventBus ?? makeEventBus()
50
59
  const mp = mapProvider ?? makeMapProvider()
51
60
  const dp = dispatch ?? jest.fn()
52
- const { result, unmount } = renderHook(() => useMapItemList({
53
- mapState: { markers: markers ?? makeMarkers() },
61
+ // A real registry, not a mock — with only interact registered (additive, the default) it
62
+ // behaves exactly like the old direct-emit code did (same items, same multiselectable), so
63
+ // every existing eb.emit(SET_FEATURES, ...) assertion below still holds unchanged. The
64
+ // registry's own aggregation/exclusive-claim logic is covered separately, in
65
+ // spatialListRegistry.test.js.
66
+ const spatialListRegistry = createSpatialListRegistry({ eventBus: eb })
67
+ const { result, unmount } = renderHook(() => useSpatialList({
68
+ mapState: { markers: markers ?? makeMarkers(), mapSize },
54
69
  pluginState: { interactionModes, layers, dispatch: dp, multiSelect },
55
70
  services: { eventBus: eb },
56
- mapProvider: mp
71
+ mapProvider: mp,
72
+ spatialListRegistry
57
73
  }))
58
- return { eb, mp, dp, result, unmount }
74
+ return { eb, mp, dp, result, unmount, spatialListRegistry }
75
+ }
76
+
77
+ // A marker "matching" an id now requires it to actually have made it into the built item list
78
+ // (visible, labelled, in-viewport) — not just exist in markers.items — since useActiveItemHandler
79
+ // resolves purely by looking the id up in that same list. This helper builds one that will.
80
+ const setupVisibleMarker = (overrides = {}) => {
81
+ const { el, container } = makeMarkerEl({ inViewport: true })
82
+ const markers = makeMarkers([{ id: 'm1', label: 'Marker', symbol: 'pin', isVisible: true, ...overrides }])
83
+ markers.markerRefs.set('m1', el)
84
+ return { markers, container }
59
85
  }
60
86
 
61
- // ─── useMapItemList — lifecycle ──────────────────────────────────────────
87
+ // ─── useSpatialList — lifecycle ──────────────────────────────────────────
62
88
 
63
- describe('useMapItemList — lifecycle', () => {
89
+ describe('useSpatialList — lifecycle', () => {
64
90
  it('subscribes to map:moveend on mount', () => {
65
91
  const { eb } = setup()
66
92
  expect(eb.on).toHaveBeenCalledWith(MOVE_END, expect.any(Function))
67
93
  })
68
94
 
95
+ it('registers an "interact" item provider with spatialListRegistry on mount', () => {
96
+ const eb = makeEventBus()
97
+ const spatialListRegistry = createSpatialListRegistry({ eventBus: eb })
98
+ const registerSpy = jest.spyOn(spatialListRegistry, 'registerItemProvider')
99
+ const { unmount } = renderHook(() => useSpatialList({
100
+ mapState: { markers: makeMarkers(), mapSize: 'small' },
101
+ pluginState: { interactionModes: [], layers: [], dispatch: jest.fn(), multiSelect: false },
102
+ services: { eventBus: eb },
103
+ mapProvider: makeMapProvider(),
104
+ spatialListRegistry
105
+ }))
106
+ expect(registerSpy).toHaveBeenCalledWith('interact', { getItems: expect.any(Function) })
107
+ unmount()
108
+ })
109
+
110
+ it('unregisters the "interact" item provider on unmount', () => {
111
+ const { spatialListRegistry, unmount } = setup()
112
+ const unregisterSpy = jest.spyOn(spatialListRegistry, 'unregisterItemProvider')
113
+ unmount()
114
+ expect(unregisterSpy).toHaveBeenCalledWith('interact')
115
+ })
116
+
117
+ it('does not re-register the provider just because the map moves — it registers once for the hook\'s lifetime', () => {
118
+ const { eb, spatialListRegistry } = setup({ interactionModes: ['selectMarker'] })
119
+ const registerSpy = jest.spyOn(spatialListRegistry, 'registerItemProvider')
120
+ act(() => eb.emit(MOVE_END, {}))
121
+ act(() => eb.emit(DATA_CHANGE, {}))
122
+ expect(registerSpy).not.toHaveBeenCalled()
123
+ })
124
+
69
125
  it('subscribes to map:datachange on mount', () => {
70
126
  const { eb } = setup()
71
127
  expect(eb.on).toHaveBeenCalledWith(DATA_CHANGE, expect.any(Function))
@@ -84,9 +140,9 @@ describe('useMapItemList — lifecycle', () => {
84
140
  })
85
141
  })
86
142
 
87
- // ─── useMapItemList — initial population ─────────────────────────────────
143
+ // ─── useSpatialList — initial population ─────────────────────────────────
88
144
 
89
- describe('useMapItemList — initial population', () => {
145
+ describe('useSpatialList — initial population', () => {
90
146
  afterEach(() => { document.body.innerHTML = '' })
91
147
 
92
148
  it('emits visible markers immediately on mount without waiting for moveend', () => {
@@ -97,20 +153,20 @@ describe('useMapItemList — initial population', () => {
97
153
  const { eb } = setup({ interactionModes: ['selectMarker'], markers })
98
154
 
99
155
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
100
- items: [{ id: 'm1', label: MARKER_LABEL }], multiselectable: false
156
+ items: [markerItem({ id: 'm1', label: MARKER_LABEL })], multiselectable: false, label: 'Map features'
101
157
  })
102
158
  container.remove()
103
159
  })
104
160
 
105
161
  it('emits empty items immediately when no markers are in viewport', () => {
106
162
  const { eb } = setup({ interactionModes: ['selectMarker'] })
107
- expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
163
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false, label: 'Map features' })
108
164
  })
109
165
  })
110
166
 
111
- // ─── useMapItemList — datachange trigger ─────────────────────────────────
167
+ // ─── useSpatialList — datachange trigger ─────────────────────────────────
112
168
 
113
- describe('useMapItemList — datachange trigger', () => {
169
+ describe('useSpatialList — datachange trigger', () => {
114
170
  afterEach(() => { document.body.innerHTML = '' })
115
171
 
116
172
  it('re-emits items on map:datachange the same as map:moveend', () => {
@@ -122,15 +178,15 @@ describe('useMapItemList — datachange trigger', () => {
122
178
  act(() => eb.emit(DATA_CHANGE, {}))
123
179
 
124
180
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
125
- items: [{ id: 'm1', label: MARKER_LABEL }], multiselectable: false
181
+ items: [markerItem({ id: 'm1', label: MARKER_LABEL })], multiselectable: false, label: 'Map features'
126
182
  })
127
183
  container.remove()
128
184
  })
129
185
  })
130
186
 
131
- // ─── useMapItemList — selectMarker mode ───────────────────────────────────
187
+ // ─── useSpatialList — selectMarker mode ───────────────────────────────────
132
188
 
133
- describe('useMapItemList — selectMarker mode', () => {
189
+ describe('useSpatialList — selectMarker mode', () => {
134
190
  afterEach(() => { document.body.innerHTML = '' })
135
191
 
136
192
  it('emits visible markers as items on moveend', () => {
@@ -142,7 +198,7 @@ describe('useMapItemList — selectMarker mode', () => {
142
198
  act(() => eb.emit(MOVE_END, {}))
143
199
 
144
200
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
145
- items: [{ id: 'm1', label: MARKER_LABEL }], multiselectable: false
201
+ items: [markerItem({ id: 'm1', label: MARKER_LABEL })], multiselectable: false, label: 'Map features'
146
202
  })
147
203
  container.remove()
148
204
  })
@@ -156,7 +212,7 @@ describe('useMapItemList — selectMarker mode', () => {
156
212
  const { eb } = setup({ interactionModes: ['selectMarker'], markers })
157
213
  act(() => eb.emit(MOVE_END, {}))
158
214
 
159
- expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
215
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false, label: 'Map features' })
160
216
  el.remove()
161
217
  })
162
218
 
@@ -168,7 +224,7 @@ describe('useMapItemList — selectMarker mode', () => {
168
224
  const { eb } = setup({ interactionModes: ['selectMarker'], markers })
169
225
  act(() => eb.emit(MOVE_END, {}))
170
226
 
171
- expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
227
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false, label: 'Map features' })
172
228
  container.remove()
173
229
  })
174
230
 
@@ -180,7 +236,7 @@ describe('useMapItemList — selectMarker mode', () => {
180
236
  const { eb } = setup({ interactionModes: ['selectMarker'], markers })
181
237
  act(() => eb.emit(MOVE_END, {}))
182
238
 
183
- expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
239
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false, label: 'Map features' })
184
240
  container.remove()
185
241
  })
186
242
 
@@ -192,14 +248,47 @@ describe('useMapItemList — selectMarker mode', () => {
192
248
  const { eb } = setup({ interactionModes: ['selectMarker'], markers })
193
249
  act(() => eb.emit(MOVE_END, {}))
194
250
 
195
- expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
251
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false, label: 'Map features' })
252
+ container.remove()
253
+ })
254
+
255
+ it('includes x/y screen position, scaled for mapSize, when the marker has coords', () => {
256
+ const { el, container } = makeMarkerEl({ inViewport: true })
257
+ const markers = makeMarkers([{ id: 'm1', label: MARKER_LABEL, symbol: 'pin', isVisible: true, coords: [-2.4, 54.5] }])
258
+ markers.markerRefs.set('m1', el)
259
+ const mp = makeMapProvider()
260
+ mp.mapToScreen.mockReturnValue({ x: 100, y: 200 })
261
+
262
+ const { eb } = setup({ interactionModes: ['selectMarker'], markers, mapProvider: mp, mapSize: 'medium' })
263
+ act(() => eb.emit(MOVE_END, {}))
264
+
265
+ expect(mp.mapToScreen).toHaveBeenCalledWith([-2.4, 54.5])
266
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
267
+ items: [markerItem({ id: 'm1', label: MARKER_LABEL, x: 150, y: 300 })], multiselectable: false, label: 'Map features' // scaleFactor.medium = 1.5
268
+ })
269
+ container.remove()
270
+ })
271
+
272
+ it('omits x/y when the marker has no coords, without calling mapToScreen', () => {
273
+ const { el, container } = makeMarkerEl({ inViewport: true })
274
+ const markers = makeMarkers([{ id: 'm1', label: MARKER_LABEL, symbol: 'pin', isVisible: true }])
275
+ markers.markerRefs.set('m1', el)
276
+ const mp = makeMapProvider()
277
+
278
+ const { eb } = setup({ interactionModes: ['selectMarker'], markers, mapProvider: mp })
279
+ act(() => eb.emit(MOVE_END, {}))
280
+
281
+ expect(mp.mapToScreen).not.toHaveBeenCalled()
282
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
283
+ items: [markerItem({ id: 'm1', label: MARKER_LABEL })], multiselectable: false, label: 'Map features'
284
+ })
196
285
  container.remove()
197
286
  })
198
287
  })
199
288
 
200
- // ─── useMapItemList — selectFeature mode: label resolution ───────────────
289
+ // ─── useSpatialList — selectFeature mode: label resolution ───────────────
201
290
 
202
- describe('useMapItemList — selectFeature mode: label resolution', () => {
291
+ describe('useSpatialList — selectFeature mode: label resolution', () => {
203
292
  const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
204
293
 
205
294
  it('emits layer features as items on moveend', () => {
@@ -216,7 +305,9 @@ describe('useMapItemList — selectFeature mode: label resolution', () => {
216
305
 
217
306
  expect(mp.getVisibleFeatures).toHaveBeenCalledWith(['roads'])
218
307
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
219
- items: [{ id: '1', label: 'High Street' }], multiselectable: false
308
+ items: [featureItem({ id: '1', label: 'High Street', featureId: '1', layerId: 'roads', idProperty: 'road_id' })],
309
+ multiselectable: false,
310
+ label: 'Map features'
220
311
  })
221
312
  })
222
313
 
@@ -225,7 +316,7 @@ describe('useMapItemList — selectFeature mode: label resolution', () => {
225
316
  const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
226
317
  act(() => eb.emit(MOVE_END, {}))
227
318
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
228
- items: [{ id: '2', label: '2' }], multiselectable: false
319
+ items: [featureItem({ id: '2', label: '2' })], multiselectable: false, label: 'Map features'
229
320
  })
230
321
  })
231
322
 
@@ -238,7 +329,7 @@ describe('useMapItemList — selectFeature mode: label resolution', () => {
238
329
  })
239
330
  act(() => eb.emit(MOVE_END, {}))
240
331
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
241
- items: [], multiselectable: false
332
+ items: [], multiselectable: false, label: 'Map features'
242
333
  })
243
334
  })
244
335
 
@@ -247,7 +338,7 @@ describe('useMapItemList — selectFeature mode: label resolution', () => {
247
338
  const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
248
339
  act(() => eb.emit(MOVE_END, {}))
249
340
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
250
- items: [{ id: '42', label: 'Oak Ave' }], multiselectable: false
341
+ items: [featureItem({ id: '42', label: 'Oak Ave' })], multiselectable: false, label: 'Map features'
251
342
  })
252
343
  })
253
344
 
@@ -255,16 +346,16 @@ describe('useMapItemList — selectFeature mode: label resolution', () => {
255
346
  const features = [{ layer: { id: 'roads' }, properties: { road_name: 'Lost Lane' } }]
256
347
  const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
257
348
  act(() => eb.emit(MOVE_END, {}))
258
- expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
349
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false, label: 'Map features' })
259
350
  })
260
351
 
261
352
  it('falls back to stringId label when feature has no properties object', () => {
262
- // Line 45: feature.properties is undefined → ?. short-circuits → ?? stringId used
353
+ // feature.properties is undefined → ?. short-circuits → ?? stringId used
263
354
  const features = [{ layer: { id: 'roads' }, id: 99 }]
264
355
  const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
265
356
  act(() => eb.emit(MOVE_END, {}))
266
357
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
267
- items: [{ id: '99', label: '99' }], multiselectable: false
358
+ items: [featureItem({ id: '99', label: '99' })], multiselectable: false, label: 'Map features'
268
359
  })
269
360
  })
270
361
 
@@ -280,27 +371,58 @@ describe('useMapItemList — selectFeature mode: label resolution', () => {
280
371
  })
281
372
  act(() => eb.emit(MOVE_END, {}))
282
373
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
283
- items: [{ id: '1', label: 'High Street' }], multiselectable: false
374
+ items: [featureItem({ id: '1', label: 'High Street' })], multiselectable: false, label: 'Map features'
375
+ })
376
+ })
377
+
378
+ it('includes x/y at the bbox centre of the feature geometry, scaled for mapSize', () => {
379
+ const features = [{
380
+ layer: { id: 'roads' },
381
+ properties: { road_id: '1', road_name: 'High Street' },
382
+ geometry: { type: 'Polygon', coordinates: [[[0, 0], [4, 0], [4, 2], [0, 2], [0, 0]]] }
383
+ }]
384
+ const mp = makeMapProvider(features)
385
+ mp.mapToScreen.mockReturnValue({ x: 100, y: 200 })
386
+
387
+ const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: mp, mapSize: 'medium' })
388
+ act(() => eb.emit(MOVE_END, {}))
389
+
390
+ expect(mp.mapToScreen).toHaveBeenCalledWith([2, 1]) // bbox centre of the polygon above
391
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
392
+ items: [featureItem({ id: '1', label: 'High Street', x: 150, y: 300 })], multiselectable: false, label: 'Map features' // scaleFactor.medium = 1.5
393
+ })
394
+ })
395
+
396
+ it('omits x/y when the feature has no geometry, without calling mapToScreen', () => {
397
+ const features = [{ layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'High Street' } }]
398
+ const mp = makeMapProvider(features)
399
+
400
+ const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: mp })
401
+ act(() => eb.emit(MOVE_END, {}))
402
+
403
+ expect(mp.mapToScreen).not.toHaveBeenCalled()
404
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
405
+ items: [featureItem({ id: '1', label: 'High Street' })], multiselectable: false, label: 'Map features'
284
406
  })
285
407
  })
286
408
  })
287
409
 
288
- // ─── useMapItemList — selectFeature mode: guards ─────────────────────────
410
+ // ─── useSpatialList — selectFeature mode: guards ─────────────────────────
289
411
 
290
- describe('useMapItemList — selectFeature mode: guards', () => {
412
+ describe('useSpatialList — selectFeature mode: guards', () => {
291
413
  const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
292
414
 
293
415
  it('skips features with no matching layer config', () => {
294
416
  const features = [{ layer: { id: 'unknown-layer' }, properties: { road_id: '4' } }]
295
417
  const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
296
418
  act(() => eb.emit(MOVE_END, {}))
297
- expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
419
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false, label: 'Map features' })
298
420
  })
299
421
 
300
422
  it('emits empty items when layers array is empty', () => {
301
423
  const { eb } = setup({ interactionModes: ['selectFeature'], layers: [] })
302
424
  act(() => eb.emit(MOVE_END, {}))
303
- expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
425
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false, label: 'Map features' })
304
426
  })
305
427
 
306
428
  it('includes multiselectable: true in payload when multiSelect is enabled', () => {
@@ -313,14 +435,14 @@ describe('useMapItemList — selectFeature mode: guards', () => {
313
435
  })
314
436
  act(() => eb.emit(MOVE_END, {}))
315
437
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
316
- items: [{ id: '1', label: 'High St' }], multiselectable: true
438
+ items: [featureItem({ id: '1', label: 'High St' })], multiselectable: true, label: 'Map features'
317
439
  })
318
440
  })
319
441
  })
320
442
 
321
- // ─── useMapItemList — combined modes ─────────────────────────────────────
443
+ // ─── useSpatialList — combined modes ─────────────────────────────────────
322
444
 
323
- describe('useMapItemList — combined modes', () => {
445
+ describe('useSpatialList — combined modes', () => {
324
446
  afterEach(() => { document.body.innerHTML = '' })
325
447
 
326
448
  it('emits both markers and features when both modes are active', () => {
@@ -341,7 +463,9 @@ describe('useMapItemList — combined modes', () => {
341
463
  act(() => eb.emit(MOVE_END, {}))
342
464
 
343
465
  expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
344
- items: [{ id: 'm1', label: 'A Marker' }, { id: '1', label: 'Main Rd' }], multiselectable: false
466
+ items: [markerItem({ id: 'm1', label: 'A Marker' }), featureItem({ id: '1', label: 'Main Rd' })],
467
+ multiselectable: false,
468
+ label: 'Map features'
345
469
  })
346
470
  container.remove()
347
471
  })
@@ -349,13 +473,13 @@ describe('useMapItemList — combined modes', () => {
349
473
  it('emits empty items when no interaction modes are active', () => {
350
474
  const { eb } = setup({ interactionModes: [] })
351
475
  act(() => eb.emit(MOVE_END, {}))
352
- expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
476
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false, label: 'Map features' })
353
477
  })
354
478
  })
355
479
 
356
- // ─── useMapItemList — map:setactivefeature listener ──────────────────────
480
+ // ─── useSpatialList — map:setactivefeature listener ──────────────────────
357
481
 
358
- describe('useMapItemList — map:setactivefeature listener', () => {
482
+ describe('useSpatialList — map:setactivefeature listener', () => {
359
483
  afterEach(() => { document.body.innerHTML = '' })
360
484
 
361
485
  it('subscribes to map:setactivefeature on mount and unsubscribes on unmount', () => {
@@ -372,10 +496,11 @@ describe('useMapItemList — map:setactivefeature listener', () => {
372
496
  })
373
497
 
374
498
  it('dispatches SET_LISTBOX_ACTIVE null when id matches a marker (ring handled by Markers.jsx)', () => {
375
- const markers = makeMarkers([{ id: 'm1', label: 'Marker', symbol: 'pin', isVisible: true }])
499
+ const { markers, container } = setupVisibleMarker()
376
500
  const { eb, dp } = setup({ interactionModes: ['selectMarker'], markers })
377
501
  act(() => eb.emit(SET_ACTIVE, { id: 'm1' })) // NOSONAR
378
502
  expect(dp).toHaveBeenCalledWith({ type: 'SET_LISTBOX_ACTIVE', payload: null })
503
+ container.remove()
379
504
  })
380
505
 
381
506
  it('dispatches SET_LISTBOX_ACTIVE with feature payload when id matches a feature', () => {
@@ -401,9 +526,9 @@ describe('useMapItemList — map:setactivefeature listener', () => {
401
526
  })
402
527
 
403
528
  it('preserves raw numeric featureId in SET_LISTBOX_ACTIVE payload (MapLibre filter type-strictness)', () => {
404
- const layers = [{ layerId: 'hedges', idProperty: 'id' }]
529
+ const layers = [{ layerId: 'hedges', idProperty: 'id', labelProperty: 'name' }]
405
530
  const features = [
406
- { layer: { id: 'hedges' }, properties: { id: 27665979 }, geometry: { type: 'LineString' } }
531
+ { layer: { id: 'hedges' }, properties: { id: 27665979, name: 'Hedge' }, geometry: { type: 'LineString' } }
407
532
  ]
408
533
  const { eb, dp } = setup({
409
534
  interactionModes: ['selectFeature'],
@@ -422,8 +547,8 @@ describe('useMapItemList — map:setactivefeature listener', () => {
422
547
  })
423
548
  })
424
549
 
425
- it('does not dispatch when feature id is not found in visible features', () => {
426
- const layers = [{ layerId: 'roads', idProperty: 'road_id' }]
550
+ it('does not dispatch when the id was never part of the built item list', () => {
551
+ const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
427
552
  const { eb, dp } = setup({
428
553
  interactionModes: ['selectFeature'],
429
554
  layers,
@@ -433,8 +558,7 @@ describe('useMapItemList — map:setactivefeature listener', () => {
433
558
  expect(dp).not.toHaveBeenCalled()
434
559
  })
435
560
 
436
- it('skips features from unknown layers when searching by id', () => {
437
- // Line 69: getFeatureId returns null for unknown layer → rawId != null is false
561
+ it('does not dispatch for a feature from an unknown layer it was never built into an item', () => {
438
562
  const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
439
563
  const features = [{ layer: { id: 'unknown' }, properties: { some_id: '1' }, geometry: { type: 'Point' } }]
440
564
  const { eb, dp } = setup({
@@ -446,8 +570,7 @@ describe('useMapItemList — map:setactivefeature listener', () => {
446
570
  expect(dp).not.toHaveBeenCalled()
447
571
  })
448
572
 
449
- it('does not search features when interactionModes excludes selectFeature', () => {
450
- // Line 114: interactionModes.includes('selectFeature') → false → block skipped
573
+ it('does not dispatch for a feature id when interactionModes excludes selectFeature — no feature items were built', () => {
451
574
  const features = [{ layer: { id: 'roads' }, properties: { road_id: '1' }, geometry: { type: 'Point' } }]
452
575
  const { eb, dp } = setup({
453
576
  interactionModes: ['selectMarker'],
@@ -458,17 +581,38 @@ describe('useMapItemList — map:setactivefeature listener', () => {
458
581
  expect(dp).not.toHaveBeenCalled()
459
582
  })
460
583
 
461
- it('does not search features when layers is empty', () => {
462
- // Line 114: layers.length > 0 → false → block skipped
584
+ it('does not dispatch for a feature id when layers is empty — no feature items were built', () => {
463
585
  const { eb, dp } = setup({ interactionModes: ['selectFeature'], layers: [] })
464
586
  act(() => eb.emit(SET_ACTIVE, { id: '1' }))
465
587
  expect(dp).not.toHaveBeenCalled()
466
588
  })
589
+
590
+ it('resolves the active item from the already-built list, without re-querying the map engine', () => {
591
+ const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
592
+ const features = [
593
+ { layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'High St' }, geometry: { type: 'Point' } },
594
+ { layer: { id: 'roads' }, properties: { road_id: '2', road_name: 'Low St' }, geometry: { type: 'Point' } }
595
+ ]
596
+ const mp = makeMapProvider(features)
597
+ const { eb, dp } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: mp })
598
+
599
+ expect(mp.getVisibleFeatures).toHaveBeenCalledTimes(1) // once, building the list on mount
600
+
601
+ act(() => eb.emit(SET_ACTIVE, { id: '1' }))
602
+ act(() => eb.emit(SET_ACTIVE, { id: '2' }))
603
+ act(() => eb.emit(SET_ACTIVE, { id: '1' }))
604
+
605
+ expect(mp.getVisibleFeatures).toHaveBeenCalledTimes(1) // still just the once
606
+ expect(dp).toHaveBeenLastCalledWith({
607
+ type: 'SET_LISTBOX_ACTIVE',
608
+ payload: { featureId: '1', layerId: 'roads', idProperty: 'road_id', geometry: { type: 'Point' } }
609
+ })
610
+ })
467
611
  })
468
612
 
469
- // ─── useMapItemList — confirm: lifecycle and guards ──────────────────────
613
+ // ─── useSpatialList — confirm: lifecycle and guards ──────────────────────
470
614
 
471
- describe('useMapItemList — confirm: lifecycle and guards', () => {
615
+ describe('useSpatialList — confirm: lifecycle and guards', () => {
472
616
  it('subscribes to map:confirmfeature on mount and unsubscribes on unmount', () => {
473
617
  const { eb, unmount } = setup()
474
618
  expect(eb.on).toHaveBeenCalledWith(CONFIRM, expect.any(Function))
@@ -483,23 +627,24 @@ describe('useMapItemList — confirm: lifecycle and guards', () => {
483
627
  })
484
628
 
485
629
  it('keeps the active item after confirm so repeated confirms dispatch again', () => {
486
- const markers = makeMarkers([{ id: 'm1', label: 'Marker', symbol: 'pin', isVisible: true }])
630
+ const { markers, container } = setupVisibleMarker()
487
631
  const { eb, dp } = setup({ interactionModes: ['selectMarker'], markers })
488
632
  act(() => eb.emit(SET_ACTIVE, { id: 'm1' }))
489
633
  act(() => eb.emit(CONFIRM))
490
634
  dp.mockClear()
491
635
  act(() => eb.emit(CONFIRM))
492
636
  expect(dp).toHaveBeenCalledWith({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: 'm1', multiSelect: false } })
637
+ container.remove()
493
638
  })
494
639
  })
495
640
 
496
- // ─── useMapItemList — confirm: dispatches ────────────────────────────────
641
+ // ─── useSpatialList — confirm: dispatches ────────────────────────────────
497
642
 
498
- describe('useMapItemList — confirm: marker dispatches', () => {
643
+ describe('useSpatialList — confirm: marker dispatches', () => {
499
644
  afterEach(() => { document.body.innerHTML = '' })
500
645
 
501
646
  it('dispatches TOGGLE_SELECTED_MARKERS after activating a marker', () => {
502
- const markers = makeMarkers([{ id: 'm1', label: 'Marker', symbol: 'pin', isVisible: true }])
647
+ const { markers, container } = setupVisibleMarker()
503
648
  const { eb, dp } = setup({ interactionModes: ['selectMarker'], markers })
504
649
  act(() => eb.emit(SET_ACTIVE, { id: 'm1' }))
505
650
  act(() => eb.emit(CONFIRM))
@@ -507,10 +652,11 @@ describe('useMapItemList — confirm: marker dispatches', () => {
507
652
  type: 'TOGGLE_SELECTED_MARKERS',
508
653
  payload: { markerId: 'm1', multiSelect: false }
509
654
  })
655
+ container.remove()
510
656
  })
511
657
  })
512
658
 
513
- describe('useMapItemList — confirm: feature dispatches', () => {
659
+ describe('useSpatialList — confirm: feature dispatches', () => {
514
660
  it('dispatches TOGGLE_SELECTED_FEATURES after activating a feature', () => {
515
661
  const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
516
662
  const features = [
@@ -557,9 +703,9 @@ describe('useMapItemList — confirm: feature dispatches', () => {
557
703
  })
558
704
 
559
705
  it('preserves raw numeric featureId in TOGGLE_SELECTED_FEATURES payload', () => {
560
- const layers = [{ layerId: 'hedges', idProperty: 'id' }]
706
+ const layers = [{ layerId: 'hedges', idProperty: 'id', labelProperty: 'name' }]
561
707
  const features = [
562
- { layer: { id: 'hedges' }, properties: { id: 27665979 }, geometry: { type: 'LineString' } }
708
+ { layer: { id: 'hedges' }, properties: { id: 27665979, name: 'Hedge' }, geometry: { type: 'LineString' } }
563
709
  ]
564
710
  const { eb, dp } = setup({
565
711
  interactionModes: ['selectFeature'],
@@ -576,7 +722,7 @@ describe('useMapItemList — confirm: feature dispatches', () => {
576
722
  replaceAll: true,
577
723
  layerId: 'hedges',
578
724
  idProperty: 'id',
579
- properties: { id: 27665979 },
725
+ properties: { id: 27665979, name: 'Hedge' },
580
726
  geometry: { type: 'LineString' }
581
727
  }
582
728
  })
@@ -9,8 +9,12 @@ import { selectFeature } from './api/selectFeature.js'
9
9
  import { unselectFeature } from './api/unselectFeature.js'
10
10
  import { selectMarker } from './api/selectMarker.js'
11
11
  import { unselectMarker } from './api/unselectMarker.js'
12
+ import { isMac } from '../../../src/utils/isMac.js'
12
13
 
13
14
  const SELECT_FEATURES_GROUP = 'Select'
15
+ // Matches the map's own label-navigation shortcut (src/App/controls/keyboardShortcuts.js) —
16
+ // same modifier, same Mac-aware label, different subject depending on what has focus.
17
+ const altKeyHtml = isMac() ? '<kbd>Option</kbd>' : '<kbd>Alt</kbd>'
14
18
 
15
19
  export const manifest = {
16
20
  InitComponent: InteractInit,
@@ -56,9 +60,16 @@ export const manifest = {
56
60
  id: 'navigateFeatures',
57
61
  group: SELECT_FEATURES_GROUP,
58
62
  context: 'listbox',
59
- title: 'Navigate features',
63
+ title: 'Highlight next feature',
60
64
  command: '<kbd>↑</kbd> or <kbd>↓</kbd>'
61
65
  },
66
+ {
67
+ id: 'navigateFeaturesSpatially',
68
+ group: SELECT_FEATURES_GROUP,
69
+ context: 'listbox',
70
+ title: 'Highlight nearby feature',
71
+ command: `${altKeyHtml} + <kbd>↑</kbd>, <kbd>↓</kbd>, <kbd>←</kbd> or <kbd>→</kbd>`
72
+ },
62
73
  {
63
74
  id: 'selectFeature',
64
75
  group: SELECT_FEATURES_GROUP,
@@ -53,3 +53,23 @@ describe('manifest', () => {
53
53
  })
54
54
  })
55
55
  })
56
+
57
+ // altKeyHtml is resolved once at module load (see manifest.js), so exercising both branches
58
+ // needs a fresh require() under each platform value — same approach as
59
+ // src/App/controls/keyboardShortcuts.test.js's own isMac coverage.
60
+ describe('manifest — Alt/Option key label', () => {
61
+ beforeEach(() => jest.resetModules())
62
+
63
+ const loadSpatialShortcut = () =>
64
+ require('./manifest.js').manifest.keyboardShortcuts.find(s => s.id === 'navigateFeaturesSpatially')
65
+
66
+ it('uses Option on Mac', () => {
67
+ Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true })
68
+ expect(loadSpatialShortcut().command).toContain('<kbd>Option</kbd>')
69
+ })
70
+
71
+ it('uses Alt on non-Mac', () => {
72
+ Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
73
+ expect(loadSpatialShortcut().command).toContain('<kbd>Alt</kbd>')
74
+ })
75
+ })