@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
@@ -45,6 +45,38 @@ describe('attachEvents — keyboard', () => {
45
45
  expect(params.handleInteraction).toHaveBeenCalled()
46
46
  })
47
47
 
48
+ it('ignores Alt+Enter (a different shortcut, e.g. highlightLabelAtCenter) on viewport', () => {
49
+ const params = createParams()
50
+ cleanup = attachEvents(params)
51
+
52
+ const keydown = new KeyboardEvent('keydown', { key: 'Enter', altKey: true })
53
+ Object.defineProperty(keydown, 'target', { value: document.body })
54
+ document.dispatchEvent(keydown)
55
+
56
+ const keyup = new KeyboardEvent('keyup', { key: 'Enter', altKey: true })
57
+ Object.defineProperty(keyup, 'target', { value: document.body })
58
+ document.dispatchEvent(keyup)
59
+
60
+ expect(params.handleInteraction).not.toHaveBeenCalled()
61
+ })
62
+
63
+ it('ignores other modified Enter combinations (Ctrl/Meta/Shift+Enter) on viewport', () => {
64
+ const params = createParams()
65
+ cleanup = attachEvents(params)
66
+
67
+ ;['ctrlKey', 'metaKey', 'shiftKey'].forEach((modifier) => {
68
+ const keydown = new KeyboardEvent('keydown', { key: 'Enter', [modifier]: true })
69
+ Object.defineProperty(keydown, 'target', { value: document.body })
70
+ document.dispatchEvent(keydown)
71
+
72
+ const keyup = new KeyboardEvent('keyup', { key: 'Enter', [modifier]: true })
73
+ Object.defineProperty(keyup, 'target', { value: document.body })
74
+ document.dispatchEvent(keyup)
75
+ })
76
+
77
+ expect(params.handleInteraction).not.toHaveBeenCalled()
78
+ })
79
+
48
80
  it('ignores Enter outside viewport or other keys', () => {
49
81
  const params = createParams()
50
82
  cleanup = attachEvents(params)
@@ -42,4 +42,24 @@ export function useAttachEvents ({ pluginState, appState, mapState, buttonConfig
42
42
 
43
43
  return cleanupEvents
44
44
  }, [pluginState.enabled, buttonConfig, eventBus])
45
+
46
+ // crossHair is dispatch-managed state (mapReducer.js's UPDATE_CROSS_HAIR spreads into a NEW
47
+ // object every time — see updateCrossHair in mapActionsMap.js, which fires constantly, e.g.
48
+ // on every fixAtCenter()/hide()), so a `.activate` assignment made only once (inside the
49
+ // effect above, whose deps don't include it) would silently end up on a stale, discarded
50
+ // object the moment the next dispatch replaces it — exactly what CrossHair.jsx's onClick
51
+ // would then find nothing on. Keyed on mapState.crossHair itself, separate from the effect
52
+ // above, so this re-wires onto the current object every time it's replaced, not just once.
53
+ useEffect(() => {
54
+ if (!pluginState.enabled) {
55
+ return undefined
56
+ }
57
+ const activate = () => handleInteractionRef.current(mapState.crossHair.getDetail())
58
+ mapState.crossHair.activate = activate
59
+ return () => {
60
+ if (mapState.crossHair.activate === activate) {
61
+ mapState.crossHair.activate = null
62
+ }
63
+ }
64
+ }, [pluginState.enabled, mapState.crossHair])
45
65
  }
@@ -18,7 +18,7 @@ beforeEach(() => {
18
18
  props = {
19
19
  pluginState: { enabled: true },
20
20
  appState: { interfaceType: 'mouse' },
21
- mapState: { mapStyle: {} },
21
+ mapState: { mapStyle: {}, crossHair: { getDetail: jest.fn(() => ({ point: {}, coords: [] })) } },
22
22
  buttonConfig: {},
23
23
  eventBus: { emit: jest.fn() },
24
24
  handleInteraction: handleInteractionMock
@@ -63,4 +63,47 @@ describe('useAttachEvents', () => {
63
63
  renderHook(() => useAttachEvents({ ...props, pluginState: { enabled: false } }))
64
64
  expect(attachEvents).not.toHaveBeenCalled()
65
65
  })
66
+
67
+ describe('crossHair.activate — re-wired whenever mapState.crossHair is replaced', () => {
68
+ it('wires crossHair.activate on mount, calling handleInteraction with the live getDetail() result', () => {
69
+ renderHook(() => useAttachEvents(props))
70
+ expect(typeof props.mapState.crossHair.activate).toBe('function')
71
+
72
+ props.mapState.crossHair.activate()
73
+ expect(handleInteractionMock).toHaveBeenCalledWith({ point: {}, coords: [] })
74
+ })
75
+
76
+ it('re-wires onto a new crossHair object — mapReducer.js replaces it wholesale on every UPDATE_CROSS_HAIR dispatch', () => {
77
+ const { rerender } = renderHook((p) => useAttachEvents(p), { initialProps: props })
78
+ const staleCrossHair = props.mapState.crossHair
79
+
80
+ const nextProps = { ...props, mapState: { ...props.mapState, crossHair: { getDetail: jest.fn(() => ({ point: {}, coords: [] })) } } }
81
+ rerender(nextProps)
82
+
83
+ expect(staleCrossHair.activate).toBeNull() // guarded cleanup nulled the old one
84
+ expect(typeof nextProps.mapState.crossHair.activate).toBe('function')
85
+ })
86
+
87
+ it('nulls activate on unmount, guarded — leaves a newer owner alone', () => {
88
+ const { unmount } = renderHook(() => useAttachEvents(props))
89
+ const ownActivate = props.mapState.crossHair.activate
90
+ expect(typeof ownActivate).toBe('function')
91
+
92
+ unmount()
93
+ expect(props.mapState.crossHair.activate).toBeNull()
94
+ })
95
+
96
+ it('does not wire crossHair.activate when the plugin is not enabled', () => {
97
+ renderHook(() => useAttachEvents({ ...props, pluginState: { enabled: false } }))
98
+ expect(props.mapState.crossHair.activate).toBeUndefined()
99
+ })
100
+
101
+ it('leaves a newer owner\'s crossHair.activate alone on unmount — guarded, not unconditional', () => {
102
+ const { unmount } = renderHook(() => useAttachEvents(props))
103
+ const laterOwnersActivate = () => {}
104
+ props.mapState.crossHair.activate = laterOwnersActivate
105
+ unmount()
106
+ expect(props.mapState.crossHair.activate).toBe(laterOwnersActivate)
107
+ })
108
+ })
66
109
  })
@@ -8,7 +8,7 @@ export function useCrossHairVisibility ({ crossHair, enabled, selectMarkerOnly,
8
8
  const crossHairRef = useRef(crossHair)
9
9
  crossHairRef.current = crossHair
10
10
  const listboxFocusRef = useRef(false)
11
- // True while focus is inside an element (marked `data-map-keyboard-scope`) that actually routes cursor keys to the map, e.g. the viewport or MoveControl's D-pad.
11
+ // True while focus is inside an element (marked `data-map-keyboard-scope`) that actually routes cursor keys to the map, e.g. the viewport or MapControl's D-pad.
12
12
  const mapKeyboardScopedRef = useRef(false)
13
13
  const interfaceTypeRef = useRef(appState.interfaceType)
14
14
  interfaceTypeRef.current = appState.interfaceType
@@ -17,16 +17,21 @@ export function useCrossHairVisibility ({ crossHair, enabled, selectMarkerOnly,
17
17
  const type = interfaceTypeRef.current
18
18
  const keyboardCanOperateMap = type === 'keyboard' && mapKeyboardScopedRef.current
19
19
  const touchCanOperateMap = type === 'touch' && !selectMarkerOnlyRef.current && !listboxFocusRef.current
20
- if (enabledRef.current && (keyboardCanOperateMap || touchCanOperateMap)) {
20
+ // MapControls being open is an explicit "I can't drag/pinch this map" signal from the
21
+ // user — same audience this crosshair already exists for — so it counts as its own reason
22
+ // to show it, independent of the currently-inferred interface type (which, for a Voice
23
+ // Control user, will never register as anything but the default 'mouse').
24
+ const mapControlsOpen = appState.expandedButtons?.has('mapControls')
25
+ if (enabledRef.current && (keyboardCanOperateMap || touchCanOperateMap || mapControlsOpen)) {
21
26
  crossHairRef.current.fixAtCenter()
22
27
  } else {
23
28
  crossHairRef.current.hide()
24
29
  }
25
- }, [])
30
+ }, [appState.expandedButtons])
26
31
 
27
32
  useEffect(() => {
28
33
  updateCrossHair()
29
- }, [enabled, selectMarkerOnly, appState.interfaceType, updateCrossHair])
34
+ }, [enabled, selectMarkerOnly, appState.interfaceType, appState.expandedButtons, updateCrossHair])
30
35
 
31
36
  useEffect(() => {
32
37
  const container = appState.layoutRefs?.appContainerRef?.current
@@ -73,13 +73,13 @@ describe('useCrossHairVisibility', () => {
73
73
  expect(crossHair.hide).toHaveBeenCalledTimes(2)
74
74
  })
75
75
 
76
- it('shows the crosshair for a control (e.g. MoveControl) that forwards arrow keys to the map without moving DOM focus onto the viewport', () => {
76
+ it('shows the crosshair for a control (e.g. MapControl) that forwards arrow keys to the map without moving DOM focus onto the viewport', () => {
77
77
  const container = appState.layoutRefs.appContainerRef.current
78
- const moveControlButton = document.createElement('button')
79
- const moveControlScope = document.createElement('div')
80
- moveControlScope.setAttribute('data-map-keyboard-scope', '')
81
- moveControlScope.appendChild(moveControlButton)
82
- container.appendChild(moveControlScope)
78
+ const mapControlButton = document.createElement('button')
79
+ const mapControlScope = document.createElement('div')
80
+ mapControlScope.setAttribute('data-map-keyboard-scope', '')
81
+ mapControlScope.appendChild(mapControlButton)
82
+ container.appendChild(mapControlScope)
83
83
  appState.interfaceType = 'keyboard'
84
84
 
85
85
  renderHook(() => useCrossHairVisibility({ crossHair, enabled: true, selectMarkerOnly: false, appState }))
@@ -87,7 +87,7 @@ describe('useCrossHairVisibility', () => {
87
87
 
88
88
  // Focus lands on a direction button nested inside the scoped control, not
89
89
  // on the scope element itself — closest() must still pick it up.
90
- act(() => moveControlButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
90
+ act(() => mapControlButton.dispatchEvent(new FocusEvent('focusin', { bubbles: true })))
91
91
  expect(crossHair.fixAtCenter).toHaveBeenCalled()
92
92
  })
93
93
 
@@ -0,0 +1,251 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { EVENTS } from '../../../../src/config/events.js'
3
+ import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js'
4
+ import { scaleFactor } from '../../../../src/config/appConfig.js'
5
+ import { buildLayerConfigMap } from '../utils/featureQueries.js'
6
+ import { getGeometryCenter } from '../utils/spatial.js'
7
+
8
+ // Only called after toFeatureItem's own `config?.labelProperty` guard, so config is always
9
+ // truthy here — no defensive null-config branch needed (that was only for findFeatureById's own
10
+ // direct-lookup path, since removed along with the mapProvider.getVisibleFeatures re-query it did).
11
+ const getFeatureId = (feature, config) => feature.properties?.[config.idProperty] ?? feature.id
12
+
13
+ const isInViewport = (el) => {
14
+ const container = el.closest('.im-c-viewport__markers')
15
+ if (!container) {
16
+ return false
17
+ }
18
+ const containerRect = container.getBoundingClientRect()
19
+ const { left, right, top, bottom } = el.getBoundingClientRect()
20
+ return right > containerRect.left && left < containerRect.right &&
21
+ bottom > containerRect.top && top < containerRect.bottom
22
+ }
23
+
24
+ // Screen position for a [lng, lat] point, scaled for the current mapSize — same correction
25
+ // useMarkersAPI.js's projectCoords applies (the map canvas itself is rendered at a different
26
+ // resolution per mapSize; nothing to do with CSS transforms). mapProvider.mapToScreen is a
27
+ // provider-agnostic method (implemented by every provider, including OpenLayers/Esri), so this
28
+ // helper works unchanged once OpenLayers' getVisibleFeatures stops being a stub.
29
+ const projectToScreen = (mapProvider, mapSize, coords) => {
30
+ const { x, y } = mapProvider.mapToScreen(coords)
31
+ return { x: x * scaleFactor[mapSize], y: y * scaleFactor[mapSize] }
32
+ }
33
+
34
+ // x/y are the marker's own screen position — computed fresh here rather than read off
35
+ // marker.x/marker.y, which only reflect where the marker was when added/last updated, not the
36
+ // current pan position. Used to position the corresponding accessible list item so touch/voice
37
+ // AT overlays (e.g. macOS Voice Control's "Show Numbers") land on the actual feature instead of
38
+ // stacking at the top-left of the viewport.
39
+ const collectVisibleMarkers = (markers, mapProvider, mapSize) => {
40
+ const items = []
41
+ for (const marker of markers.items) {
42
+ if (!marker.label) { continue }
43
+ const el = markers.markerRefs?.get(marker.id)
44
+ if (!isStandaloneLabel(marker) && el && isInViewport(el)) {
45
+ const item = { id: marker.id, label: marker.label, isMarker: true }
46
+ if (marker.coords) {
47
+ Object.assign(item, projectToScreen(mapProvider, mapSize, marker.coords))
48
+ }
49
+ items.push(item)
50
+ }
51
+ }
52
+ return items
53
+ }
54
+
55
+ const toFeatureItem = (feature, layerConfigMap, seenIds, mapProvider, mapSize) => {
56
+ const config = layerConfigMap[feature.layer?.id]
57
+ if (!config?.labelProperty) {
58
+ return null
59
+ }
60
+ const rawId = getFeatureId(feature, config)
61
+ const stringId = rawId == null ? null : String(rawId)
62
+ if (stringId == null || seenIds.has(stringId)) {
63
+ return null
64
+ }
65
+ seenIds.add(stringId)
66
+ const label = feature.properties?.[config.labelProperty] ?? stringId
67
+ // Keeps the full resolved shape (raw id, layer config, geometry, properties) alongside the
68
+ // listbox-facing id/label — not just for building the item, but so useActiveItemHandler below
69
+ // can resolve "what is this" from this same object later, without re-querying the map engine.
70
+ // Only id/label/x/y ever reach MAP_SET_SPATIAL_LIST itself (see toPublicItem) — the rest stays
71
+ // internal, in itemsRef.
72
+ const item = {
73
+ id: stringId,
74
+ label,
75
+ isMarker: false,
76
+ featureId: rawId,
77
+ layerId: config.layerId,
78
+ idProperty: config.idProperty,
79
+ geometry: feature.geometry,
80
+ properties: feature.properties
81
+ }
82
+ // Bbox-centre of the feature's geometry (polygon or line) — see getGeometryCenter's own doc
83
+ // for why a bbox centre rather than a true centroid. null when the feature carries no usable
84
+ // geometry (e.g. OpenLayers' getVisibleFeatures, currently a stub returning [] — see PR notes).
85
+ const center = getGeometryCenter(feature)
86
+ if (center) {
87
+ Object.assign(item, projectToScreen(mapProvider, mapSize, center))
88
+ }
89
+ return item
90
+ }
91
+
92
+ const collectVisibleFeatures = (mapProvider, layers, mapSize) => {
93
+ const items = []
94
+ const seenIds = new Set()
95
+ const layerIds = layers.map(layer => layer.layerId)
96
+ const layerConfigMap = buildLayerConfigMap(layers)
97
+ const features = mapProvider.getVisibleFeatures(layerIds)
98
+ for (const feature of features) {
99
+ const item = toFeatureItem(feature, layerConfigMap, seenIds, mapProvider, mapSize)
100
+ if (item) {
101
+ items.push(item)
102
+ }
103
+ }
104
+ return items
105
+ }
106
+
107
+ /**
108
+ * Rebuilds the keyboard-navigable item list whenever the map moves or data changes. Collects
109
+ * visible markers (by DOM visibility) and visible features (by viewport query), keeps the
110
+ * result in itemsRef for useActiveItemHandler to resolve against later, and tells
111
+ * spatialListRegistry its items have changed so the shared listbox stays in sync with what's
112
+ * visible. SpatialList.jsx/useSpatialListItems.js only ever render id/label/x/y from each item — the
113
+ * rest (isMarker, geometry, properties, layer config) rides along unused there, but is what
114
+ * lets useActiveItemHandler resolve "what is this" from the same object later, with nothing to
115
+ * re-derive and no separate lean/rich shape to keep in sync.
116
+ *
117
+ * interact no longer emits MAP_SET_SPATIAL_LIST directly — spatialListRegistry owns that (see its
118
+ * own doc comment for why: a single "whoever emits last wins" event doesn't scale once more
119
+ * than one plugin, e.g. draw mid-edit, needs to contribute to or take over the same list).
120
+ */
121
+ function useItemListSync ({ markers, mapSize, interactionModes, layers, mapProvider, multiSelect, spatialListRegistry, eventBus, itemsRef }) {
122
+ const multiSelectRef = useRef(multiSelect)
123
+ multiSelectRef.current = multiSelect // always-current for the provider's getItems below, same convention as itemsRef
124
+
125
+ useEffect(() => {
126
+ const handleMoveEnd = () => {
127
+ const items = []
128
+ if (interactionModes?.includes('selectMarker')) {
129
+ items.push(...collectVisibleMarkers(markers, mapProvider, mapSize))
130
+ }
131
+ if (interactionModes?.includes('selectFeature') && layers.length > 0) {
132
+ items.push(...collectVisibleFeatures(mapProvider, layers, mapSize))
133
+ }
134
+ itemsRef.current = items
135
+ spatialListRegistry.notifyItemsChanged('interact')
136
+ }
137
+ handleMoveEnd()
138
+ eventBus.on(EVENTS.MAP_MOVE_END, handleMoveEnd)
139
+ eventBus.on(EVENTS.MAP_DATA_CHANGE, handleMoveEnd)
140
+ return () => {
141
+ eventBus.off(EVENTS.MAP_MOVE_END, handleMoveEnd)
142
+ eventBus.off(EVENTS.MAP_DATA_CHANGE, handleMoveEnd)
143
+ }
144
+ }, [markers, mapSize, interactionModes, layers, mapProvider, multiSelect, spatialListRegistry, eventBus, itemsRef])
145
+
146
+ // Registers once, for the lifetime of the hook — getItems reads itemsRef/multiSelectRef
147
+ // fresh on every call, so there's never a need to re-register just because a prop changed;
148
+ // the effect above already pushes fresh data out via notifyItemsChanged. Declared after
149
+ // that effect so this registration's own immediate recompute (inside the registry) already
150
+ // sees whatever the effect above just computed on this same mount, rather than momentarily
151
+ // registering with empty items and correcting a moment later.
152
+ useEffect(() => {
153
+ spatialListRegistry.registerItemProvider('interact', {
154
+ getItems: () => ({ items: itemsRef.current, multiselectable: multiSelectRef.current, label: 'Map features' })
155
+ })
156
+ return () => { spatialListRegistry.unregisterItemProvider('interact') }
157
+ }, [spatialListRegistry, itemsRef])
158
+ }
159
+
160
+ /**
161
+ * Listens for MAP_SET_ACTIVE_ITEM and resolves the active item to its full feature/marker data,
162
+ * storing it in both a ref (for synchronous access) and plugin state (for highlight rendering).
163
+ * Shows the keyboard cursor ring without firing interact:selectionchange — committing the item
164
+ * to the real selection only happens when the user presses Enter/Space.
165
+ *
166
+ * Resolves purely by looking the id up in itemsRef (the same list useItemListSync just built) —
167
+ * deliberately not a fresh mapProvider.getVisibleFeatures() query. This fires on every roving-
168
+ * tabindex move (every arrow-key press while browsing the list), so re-querying the map engine
169
+ * here — as an earlier version of this hook did — meant every keystroke re-ran the same query
170
+ * useItemListSync had just run moments before, plus a second linear scan through the result, to
171
+ * re-derive data already computed once and discarded. A stale/out-of-range id (e.g. the map
172
+ * panned since the list was built) simply resolves to nothing, same as before.
173
+ */
174
+ function useActiveItemHandler ({ itemsRef, eventBus, dispatch, listboxActiveItemRef }) {
175
+ useEffect(() => {
176
+ const handle = ({ id }) => {
177
+ if (id === null) {
178
+ listboxActiveItemRef.current = null
179
+ dispatch({ type: 'SET_LISTBOX_ACTIVE', payload: null })
180
+ return
181
+ }
182
+ const item = itemsRef.current.find(i => i.id === id)
183
+ if (!item) {
184
+ return
185
+ }
186
+ if (item.isMarker) {
187
+ listboxActiveItemRef.current = { id, isMarker: true }
188
+ dispatch({ type: 'SET_LISTBOX_ACTIVE', payload: null })
189
+ return
190
+ }
191
+ const payload = {
192
+ featureId: item.featureId,
193
+ layerId: item.layerId,
194
+ idProperty: item.idProperty,
195
+ geometry: item.geometry
196
+ }
197
+ listboxActiveItemRef.current = { id, isMarker: false, ...payload, properties: item.properties }
198
+ dispatch({ type: 'SET_LISTBOX_ACTIVE', payload })
199
+ }
200
+ eventBus.on(EVENTS.MAP_SET_ACTIVE_ITEM, handle)
201
+ return () => { eventBus.off(EVENTS.MAP_SET_ACTIVE_ITEM, handle) }
202
+ }, [itemsRef, eventBus, dispatch, listboxActiveItemRef])
203
+ }
204
+
205
+ /**
206
+ * Handles MAP_SELECT_ITEM (Enter/Space keypress) by promoting the currently active
207
+ * listbox item to a confirmed selection, dispatching TOGGLE_SELECTED_FEATURES or
208
+ * TOGGLE_SELECTED_MARKERS and triggering interact:selectionchange downstream.
209
+ */
210
+ function useSelectItemHandler ({ eventBus, dispatch, listboxActiveItemRef, multiSelect }) {
211
+ useEffect(() => {
212
+ const handleConfirm = () => {
213
+ const item = listboxActiveItemRef.current
214
+ if (!item) {
215
+ return
216
+ }
217
+ if (item.isMarker) {
218
+ dispatch({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: item.id, multiSelect } })
219
+ } else {
220
+ const { featureId, layerId, idProperty, geometry, properties } = item
221
+ dispatch({
222
+ type: 'TOGGLE_SELECTED_FEATURES',
223
+ payload: { featureId, layerId, idProperty, geometry, properties, multiSelect, replaceAll: !multiSelect }
224
+ })
225
+ }
226
+ }
227
+ eventBus.on(EVENTS.MAP_SELECT_ITEM, handleConfirm)
228
+ return () => { eventBus.off(EVENTS.MAP_SELECT_ITEM, handleConfirm) }
229
+ }, [eventBus, dispatch, listboxActiveItemRef, multiSelect])
230
+ }
231
+
232
+ /**
233
+ * Orchestrates the keyboard-accessible listbox for the interact plugin.
234
+ *
235
+ * Composes three concerns:
236
+ * - Item list sync — keeps the listbox populated with currently visible markers and features
237
+ * - Active item resolution — translates a listbox cursor position into full feature/marker data
238
+ * - Selection confirmation — commits the active item to the selection on Enter/Space
239
+ *
240
+ * @param {{ mapState: object, pluginState: object, services: object, mapProvider: object, spatialListRegistry: object }} params
241
+ */
242
+ export function useSpatialList ({ mapState, pluginState, services, mapProvider, spatialListRegistry }) {
243
+ const { markers, mapSize } = mapState
244
+ const { dispatch, interactionModes, layers, multiSelect } = pluginState
245
+ const { eventBus } = services
246
+ const listboxActiveItemRef = useRef(null)
247
+ const itemsRef = useRef([])
248
+ useItemListSync({ markers, mapSize, interactionModes, layers, mapProvider, multiSelect, spatialListRegistry, eventBus, itemsRef })
249
+ useActiveItemHandler({ itemsRef, eventBus, dispatch, listboxActiveItemRef })
250
+ useSelectItemHandler({ eventBus, dispatch, listboxActiveItemRef, multiSelect })
251
+ }