@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
@@ -0,0 +1,339 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useSpatialList } from './useSpatialList.js'
3
+ import { createSpatialListRegistry } from '../../../../src/App/registry/spatialListRegistry.js'
4
+ import { ADAPTER_EVENTS } from '../adapterEvents.js'
5
+
6
+ const SET_FEATURES = 'map:setspatiallist'
7
+ const SET_ACTIVE = 'map:setactiveitem'
8
+ const CONFIRM = 'map:selectitem'
9
+ const MOVE_END = 'map:moveend'
10
+
11
+ const vertexItem = (overrides) => expect.objectContaining({ isMidpoint: false, ...overrides })
12
+ const midpointItem = (overrides) => expect.objectContaining({ isMidpoint: true, ...overrides })
13
+
14
+ const makeEventBus = () => {
15
+ const listeners = {}
16
+ return {
17
+ on: jest.fn((eventName, fn) => { listeners[eventName] = fn }),
18
+ off: jest.fn(),
19
+ emit: jest.fn((eventName, payload) => listeners[eventName]?.(payload))
20
+ }
21
+ }
22
+
23
+ // A minimal stand-in for the adapter surface useSpatialList.js actually calls
24
+ // (getVertexItems/selectVertex/insertVertexAtMidpoint), plus its own on/off/UPDATE bus.
25
+ const makeDrawAdapter = ({ vertices = [], midpoints = [] } = {}) => {
26
+ const listeners = {}
27
+ return {
28
+ getVertexItems: jest.fn(() => ({ vertices, midpoints })),
29
+ selectVertex: jest.fn(),
30
+ insertVertexAtMidpoint: jest.fn(),
31
+ on: jest.fn((eventName, fn) => { listeners[eventName] = fn }),
32
+ off: jest.fn(),
33
+ fireUpdate: () => listeners[ADAPTER_EVENTS.UPDATE]?.()
34
+ }
35
+ }
36
+
37
+ const makeMapProvider = (draw) => ({
38
+ draw,
39
+ mapToScreen: jest.fn(() => ({ x: 10, y: 20 }))
40
+ })
41
+
42
+ const makeViewportRef = () => ({ current: { focus: jest.fn() } })
43
+
44
+ const setup = ({ mode = 'edit_vertex', draw, eventBus, mapSize = 'small', registry, viewportRef, announce } = {}) => {
45
+ const eb = eventBus ?? makeEventBus()
46
+ const adapter = draw ?? makeDrawAdapter()
47
+ const mp = makeMapProvider(adapter)
48
+ const vpRef = viewportRef ?? makeViewportRef()
49
+ const announceSpy = announce ?? jest.fn()
50
+ // A real registry, not a mock — spatialListRegistry.test.js already covers its own
51
+ // aggregation/exclusive-claim behaviour generically; using the real thing here proves this
52
+ // hook actually drives it correctly, same convention as interact's own useSpatialList.test.js.
53
+ const spatialListRegistry = registry ?? createSpatialListRegistry({ eventBus: eb })
54
+ const { result, unmount, rerender } = renderHook(
55
+ ({ mode }) => useSpatialList({
56
+ mapState: { mapSize },
57
+ pluginState: { mode },
58
+ services: { eventBus: eb, announce: announceSpy },
59
+ mapProvider: mp,
60
+ spatialListRegistry,
61
+ viewportRef: vpRef
62
+ }),
63
+ { initialProps: { mode } }
64
+ )
65
+ return { eb, mp, adapter, result, unmount, rerender, spatialListRegistry, viewportRef: vpRef, announce: announceSpy }
66
+ }
67
+
68
+ // ─── useSpatialList — lifecycle ──────────────────────────────────────────
69
+
70
+ describe('useSpatialList — lifecycle', () => {
71
+ it('registers a "draw" item provider with spatialListRegistry on mount', () => {
72
+ const eb = makeEventBus()
73
+ const spatialListRegistry = createSpatialListRegistry({ eventBus: eb })
74
+ const registerSpy = jest.spyOn(spatialListRegistry, 'registerItemProvider')
75
+ const { unmount } = setup({ eventBus: eb, registry: spatialListRegistry })
76
+ expect(registerSpy).toHaveBeenCalledWith('draw', { getItems: expect.any(Function), exclusive: true })
77
+ unmount()
78
+ })
79
+
80
+ it('unregisters the "draw" provider on unmount', () => {
81
+ const eb = makeEventBus()
82
+ const spatialListRegistry = createSpatialListRegistry({ eventBus: eb })
83
+ const unregisterSpy = jest.spyOn(spatialListRegistry, 'unregisterItemProvider')
84
+ const { unmount } = setup({ eventBus: eb, registry: spatialListRegistry })
85
+ unmount()
86
+ expect(unregisterSpy).toHaveBeenCalledWith('draw')
87
+ })
88
+
89
+ it('subscribes to the adapter\'s UPDATE event while edit_vertex is active', () => {
90
+ const { adapter, unmount } = setup({ mode: 'edit_vertex' })
91
+ expect(adapter.on).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, expect.any(Function))
92
+ unmount()
93
+ expect(adapter.off).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, expect.any(Function))
94
+ })
95
+
96
+ it('is safe when mapProvider.draw does not exist yet', () => {
97
+ const eb = makeEventBus()
98
+ const mp = { draw: null, mapToScreen: jest.fn() }
99
+ expect(() => renderHook(() => useSpatialList({
100
+ mapState: { mapSize: 'small' },
101
+ pluginState: { mode: 'edit_vertex' },
102
+ services: { eventBus: eb },
103
+ mapProvider: mp,
104
+ spatialListRegistry: createSpatialListRegistry({ eventBus: eb })
105
+ }))).not.toThrow()
106
+ })
107
+ })
108
+
109
+ // ─── useSpatialList — item building ──────────────────────────────────────
110
+
111
+ describe('useSpatialList — item building', () => {
112
+ it('emits no items and label "Shape points" is never surfaced (no claim) outside edit_vertex', () => {
113
+ const eb = makeEventBus()
114
+ setup({ eventBus: eb, mode: 'draw_polygon' })
115
+ // Not the exclusive claimant, so its own empty items don't even reach the emit.
116
+ expect(eb.emit).not.toHaveBeenCalledWith(SET_FEATURES, expect.objectContaining({ label: 'Shape points' }))
117
+ })
118
+
119
+ it('builds vertex items — id, "Point N" label, and screen position — on entering edit_vertex', () => {
120
+ const eb = makeEventBus()
121
+ const draw = makeDrawAdapter({ vertices: [[0, 0], [1, 1]], midpoints: [] })
122
+ setup({ eventBus: eb, draw, mode: 'edit_vertex' })
123
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
124
+ items: [
125
+ vertexItem({ id: '0', label: 'Point 1', x: 10, y: 20 }),
126
+ vertexItem({ id: '1', label: 'Point 2', x: 10, y: 20 })
127
+ ],
128
+ multiselectable: false,
129
+ label: 'Shape points',
130
+ focusable: false
131
+ })
132
+ })
133
+
134
+ it('interleaves each midpoint after its vertex, with a flat id continuing the vertex index space', () => {
135
+ const eb = makeEventBus()
136
+ const draw = makeDrawAdapter({ vertices: [[0, 0], [1, 1], [2, 2]], midpoints: [[0.5, 0.5], [1.5, 1.5], [1, 1]] })
137
+ setup({ eventBus: eb, draw, mode: 'edit_vertex' })
138
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
139
+ items: [
140
+ vertexItem({ id: '0' }),
141
+ midpointItem({ id: '3', label: 'Insert 1-2' }),
142
+ vertexItem({ id: '1' }),
143
+ midpointItem({ id: '4', label: 'Insert 2-3' }),
144
+ vertexItem({ id: '2' }),
145
+ midpointItem({ id: '5', label: 'Insert 3-1' })
146
+ ],
147
+ multiselectable: false,
148
+ label: 'Shape points',
149
+ focusable: false
150
+ })
151
+ })
152
+
153
+ it('declares focusable: false — no item is ever a real Tab stop for this list', () => {
154
+ const eb = makeEventBus()
155
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [] })
156
+ setup({ eventBus: eb, draw, mode: 'edit_vertex' })
157
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, expect.objectContaining({ focusable: false }))
158
+ })
159
+
160
+ it('rebuilds on the adapter\'s UPDATE event (e.g. a vertex was inserted/moved)', () => {
161
+ const eb = makeEventBus()
162
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [] })
163
+ setup({ eventBus: eb, draw, mode: 'edit_vertex' })
164
+ eb.emit.mockClear()
165
+
166
+ draw.getVertexItems.mockReturnValue({ vertices: [[0, 0], [1, 1]], midpoints: [] })
167
+ act(() => draw.fireUpdate())
168
+
169
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, expect.objectContaining({
170
+ items: [vertexItem({ id: '0' }), vertexItem({ id: '1' })]
171
+ }))
172
+ })
173
+
174
+ it('rebuilds on MAP_MOVE_END (pan/zoom) — the underlying coordinate is unchanged but its screen position moves', () => {
175
+ const eb = makeEventBus()
176
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [] })
177
+ const { mp } = setup({ eventBus: eb, draw, mode: 'edit_vertex' })
178
+ eb.emit.mockClear()
179
+
180
+ mp.mapToScreen.mockReturnValue({ x: 99, y: 88 })
181
+ act(() => eb.emit(MOVE_END))
182
+
183
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, expect.objectContaining({
184
+ items: [vertexItem({ id: '0', x: 99, y: 88 })]
185
+ }))
186
+ })
187
+
188
+ it('reports no items when leaving edit_vertex for another mode', () => {
189
+ const eb = makeEventBus()
190
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [] })
191
+ const { rerender } = setup({ eventBus: eb, draw, mode: 'edit_vertex' })
192
+ eb.emit.mockClear()
193
+
194
+ rerender({ mode: 'disabled' })
195
+
196
+ // Once draw releases its exclusive claim (no other provider registered here), the
197
+ // registry has nothing left to emit for — items are simply gone, not merely empty.
198
+ expect(eb.emit).not.toHaveBeenCalledWith(SET_FEATURES, expect.objectContaining({ label: 'Shape points' }))
199
+ })
200
+ })
201
+
202
+ // ─── useSpatialList — exclusive claim ────────────────────────────────────
203
+
204
+ describe('useSpatialList — exclusive claim', () => {
205
+ it('claims exclusive ownership on entering edit_vertex, excluding an additive provider\'s own items', () => {
206
+ const eb = makeEventBus()
207
+ const registry = createSpatialListRegistry({ eventBus: eb })
208
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }], label: 'Map features' }) })
209
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [] })
210
+
211
+ setup({ eventBus: eb, draw, mode: 'edit_vertex', registry })
212
+
213
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, expect.objectContaining({ label: 'Shape points' }))
214
+ const lastEmit = eb.emit.mock.calls.at(-1)[1]
215
+ expect(lastEmit.items.some(i => i.id === 'feature-1')).toBe(false)
216
+ })
217
+
218
+ it('releases the claim on leaving edit_vertex, reverting to the additive provider\'s items', () => {
219
+ const eb = makeEventBus()
220
+ const registry = createSpatialListRegistry({ eventBus: eb })
221
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }], label: 'Map features' }) })
222
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [] })
223
+
224
+ const { rerender } = setup({ eventBus: eb, draw, mode: 'edit_vertex', registry })
225
+ eb.emit.mockClear()
226
+ rerender({ mode: 'disabled' })
227
+
228
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [{ id: 'feature-1' }], multiselectable: false, label: 'Map features' })
229
+ })
230
+
231
+ it('releases the claim on unmount too', () => {
232
+ const eb = makeEventBus()
233
+ const registry = createSpatialListRegistry({ eventBus: eb })
234
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }], label: 'Map features' }) })
235
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [] })
236
+
237
+ const { unmount } = setup({ eventBus: eb, draw, mode: 'edit_vertex', registry })
238
+ eb.emit.mockClear()
239
+ unmount()
240
+
241
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [{ id: 'feature-1' }], multiselectable: false, label: 'Map features' })
242
+ })
243
+ })
244
+
245
+ // ─── useSpatialList — active item preview (MAP_SET_ACTIVE_ITEM) ─────────
246
+
247
+ describe('useSpatialList — active item preview', () => {
248
+ it('previews a real vertex via selectVertex, by numeric index', () => {
249
+ const eb = makeEventBus()
250
+ const draw = makeDrawAdapter({ vertices: [[0, 0], [1, 1]], midpoints: [] })
251
+ setup({ eventBus: eb, draw, mode: 'edit_vertex' })
252
+ act(() => eb.emit(SET_ACTIVE, { id: '1' }))
253
+ expect(draw.selectVertex).toHaveBeenCalledWith(1)
254
+ })
255
+
256
+ it('previews a midpoint via selectVertex too — preview alone never inserts', () => {
257
+ const eb = makeEventBus()
258
+ const draw = makeDrawAdapter({ vertices: [[0, 0], [1, 1]], midpoints: [[0.5, 0.5]] })
259
+ setup({ eventBus: eb, draw, mode: 'edit_vertex' })
260
+ act(() => eb.emit(SET_ACTIVE, { id: '2' }))
261
+ expect(draw.selectVertex).toHaveBeenCalledWith(2)
262
+ expect(draw.insertVertexAtMidpoint).not.toHaveBeenCalled()
263
+ })
264
+
265
+ it('a null id (blur) does not call selectVertex — the selection must survive losing listbox focus', () => {
266
+ const eb = makeEventBus()
267
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [] })
268
+ setup({ eventBus: eb, draw, mode: 'edit_vertex' })
269
+ act(() => eb.emit(SET_ACTIVE, { id: null }))
270
+ expect(draw.selectVertex).not.toHaveBeenCalled()
271
+ })
272
+
273
+ it('does nothing outside edit_vertex, even if a stale event arrives', () => {
274
+ const eb = makeEventBus()
275
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [] })
276
+ setup({ eventBus: eb, draw, mode: 'draw_polygon' })
277
+ act(() => eb.emit(SET_ACTIVE, { id: '0' }))
278
+ expect(draw.selectVertex).not.toHaveBeenCalled()
279
+ })
280
+ })
281
+
282
+ // ─── useSpatialList — confirm (MAP_SELECT_ITEM) ──────────────────────────
283
+
284
+ describe('useSpatialList — confirm', () => {
285
+ it('inserts a vertex when the active item is a midpoint, announces it, and hands focus back to the viewport', () => {
286
+ const eb = makeEventBus()
287
+ const draw = makeDrawAdapter({ vertices: [[0, 0], [1, 1]], midpoints: [[0.5, 0.5]] })
288
+ const { viewportRef, announce } = setup({ eventBus: eb, draw, mode: 'edit_vertex' })
289
+ act(() => eb.emit(SET_ACTIVE, { id: '2' })) // the midpoint
290
+ act(() => eb.emit(CONFIRM))
291
+ expect(draw.insertVertexAtMidpoint).toHaveBeenCalledWith(2)
292
+ expect(announce).toHaveBeenCalledWith('New point inserted', 'action')
293
+ expect(viewportRef.current.focus).toHaveBeenCalled()
294
+ })
295
+
296
+ it('does not insert anything for a real vertex — already the live selection — but still announces it and hands focus back', () => {
297
+ const eb = makeEventBus()
298
+ const draw = makeDrawAdapter({ vertices: [[0, 0], [1, 1]], midpoints: [[0.5, 0.5]] })
299
+ const { viewportRef, announce } = setup({ eventBus: eb, draw, mode: 'edit_vertex' })
300
+ act(() => eb.emit(SET_ACTIVE, { id: '0' }))
301
+ act(() => eb.emit(CONFIRM))
302
+ expect(draw.insertVertexAtMidpoint).not.toHaveBeenCalled()
303
+ expect(announce).toHaveBeenCalledWith('Point 1 selected', 'action')
304
+ expect(viewportRef.current.focus).toHaveBeenCalled()
305
+ })
306
+
307
+ it('is a no-op — no insert, no announce, no refocus — when nothing is active yet', () => {
308
+ const eb = makeEventBus()
309
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [[0.5, 0.5]] })
310
+ const { viewportRef, announce } = setup({ eventBus: eb, draw, mode: 'edit_vertex' })
311
+ act(() => eb.emit(CONFIRM))
312
+ expect(draw.insertVertexAtMidpoint).not.toHaveBeenCalled()
313
+ expect(announce).not.toHaveBeenCalled()
314
+ expect(viewportRef.current.focus).not.toHaveBeenCalled()
315
+ })
316
+
317
+ it('is a no-op when the active id no longer matches any built item (e.g. the geometry changed underneath it)', () => {
318
+ const eb = makeEventBus()
319
+ const draw = makeDrawAdapter({ vertices: [[0, 0], [1, 1]], midpoints: [] })
320
+ const { viewportRef, announce } = setup({ eventBus: eb, draw, mode: 'edit_vertex' })
321
+ act(() => eb.emit(SET_ACTIVE, { id: '99' })) // never a built item
322
+ act(() => eb.emit(CONFIRM))
323
+ expect(draw.insertVertexAtMidpoint).not.toHaveBeenCalled()
324
+ expect(announce).not.toHaveBeenCalled()
325
+ expect(viewportRef.current.focus).not.toHaveBeenCalled()
326
+ })
327
+
328
+ it('is a no-op outside edit_vertex, even with a stale active id', () => {
329
+ const eb = makeEventBus()
330
+ const draw = makeDrawAdapter({ vertices: [[0, 0]], midpoints: [[0.5, 0.5]] })
331
+ const { rerender, viewportRef, announce } = setup({ eventBus: eb, draw, mode: 'edit_vertex' })
332
+ act(() => eb.emit(SET_ACTIVE, { id: '1' })) // the midpoint, while still in edit_vertex
333
+ rerender({ mode: 'disabled' })
334
+ act(() => eb.emit(CONFIRM))
335
+ expect(draw.insertVertexAtMidpoint).not.toHaveBeenCalled()
336
+ expect(announce).not.toHaveBeenCalled()
337
+ expect(viewportRef.current.focus).not.toHaveBeenCalled()
338
+ })
339
+ })
@@ -20,6 +20,9 @@ const isEditMode = (mode) => EDIT_MODES.has(mode)
20
20
 
21
21
  // Show the platform-appropriate undo modifier (⌘ on macOS, Ctrl elsewhere).
22
22
  const undoCommand = isMac() ? '<kbd>Command</kbd> + <kbd>Z</kbd>' : '<kbd>Ctrl</kbd> + <kbd>Z</kbd>'
23
+ // Show the platform-appropriate label for the Alt modifier — macOS keyboards and
24
+ // menus call it Option, not Alt.
25
+ const altKeyHtml = isMac() ? '<kbd>Option</kbd>' : '<kbd>Alt</kbd>'
23
26
 
24
27
  const createButtonSlots = (showLabel) => ({
25
28
  mobile: { slot: 'actions', showLabel },
@@ -131,7 +134,7 @@ export const manifest = {
131
134
  id: 'drawSelectAdjacentPoint',
132
135
  group: 'Drawing',
133
136
  title: 'Select adjacent point (edit)',
134
- command: '<kbd>Alt</kbd> + <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> or <kbd>→</kbd>'
137
+ command: `${altKeyHtml} + <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> or <kbd>→</kbd>`
135
138
  }, {
136
139
  id: 'drawMovePoint',
137
140
  group: 'Drawing',
@@ -152,13 +152,13 @@ describe('drawMenu', () => {
152
152
  })
153
153
  })
154
154
 
155
- describe('drawUndo keyboard shortcut (platform-specific command)', () => {
156
- const loadUndoCommand = (mac) => {
155
+ describe('platform-specific keyboard shortcut commands', () => {
156
+ const loadCommand = (mac, id) => {
157
157
  let command
158
158
  jest.isolateModules(() => {
159
159
  jest.doMock('../../../src/utils/isMac.js', () => ({ isMac: () => mac }))
160
160
  const { manifest: reloaded } = require('./manifest.js')
161
- command = reloaded.keyboardShortcuts.find((s) => s.id === 'drawUndo').command
161
+ command = reloaded.keyboardShortcuts.find((s) => s.id === id).command
162
162
  })
163
163
  return command
164
164
  }
@@ -168,11 +168,23 @@ describe('drawUndo keyboard shortcut (platform-specific command)', () => {
168
168
  jest.resetModules()
169
169
  })
170
170
 
171
- test('uses Command on macOS', () => {
172
- expect(loadUndoCommand(true)).toBe('<kbd>Command</kbd> + <kbd>Z</kbd>')
171
+ describe('drawUndo', () => {
172
+ test('uses Command on macOS', () => {
173
+ expect(loadCommand(true, 'drawUndo')).toBe('<kbd>Command</kbd> + <kbd>Z</kbd>')
174
+ })
175
+
176
+ test('uses Ctrl on non-mac platforms', () => {
177
+ expect(loadCommand(false, 'drawUndo')).toBe('<kbd>Ctrl</kbd> + <kbd>Z</kbd>')
178
+ })
173
179
  })
174
180
 
175
- test('uses Ctrl on non-mac platforms', () => {
176
- expect(loadUndoCommand(false)).toBe('<kbd>Ctrl</kbd> + <kbd>Z</kbd>')
181
+ describe('drawSelectAdjacentPoint', () => {
182
+ test('uses Option on macOS', () => {
183
+ expect(loadCommand(true, 'drawSelectAdjacentPoint')).toBe('<kbd>Option</kbd> + <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> or <kbd>→</kbd>')
184
+ })
185
+
186
+ test('uses Alt on non-mac platforms', () => {
187
+ expect(loadCommand(false, 'drawSelectAdjacentPoint')).toBe('<kbd>Alt</kbd> + <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> or <kbd>→</kbd>')
188
+ })
177
189
  })
178
190
  })
@@ -202,35 +202,6 @@ const isValidClick = (coords) => {
202
202
  return coords[0].length <= 1 || isNewCoordinate(coords)
203
203
  }
204
204
 
205
- const spatialNavigate = (start, pixels, direction) => {
206
- const quadrant = pixels.filter((p) => {
207
- const offsetX = Math.abs(p[0] - start[0])
208
- const offsetY = Math.abs(p[1] - start[1])
209
- let isQuadrant = false
210
- if (direction === 'ArrowUp') {
211
- isQuadrant = p[1] <= start[1] && offsetY >= offsetX
212
- } else if (direction === 'ArrowDown') {
213
- isQuadrant = p[1] > start[1] && offsetY >= offsetX
214
- } else if (direction === 'ArrowLeft') {
215
- isQuadrant = p[0] <= start[0] && offsetY < offsetX
216
- } else if (direction === 'ArrowRight') {
217
- isQuadrant = p[0] > start[0] && offsetY < offsetX
218
- } else {
219
- isQuadrant = true
220
- }
221
- return isQuadrant && (JSON.stringify(p) !== JSON.stringify(start))
222
- })
223
-
224
- if (!quadrant.length) {
225
- quadrant.push(start)
226
- }
227
-
228
- const pythagorean = (a, b) => Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2))
229
- const distances = quadrant.map(p => pythagorean(Math.abs(start[0] - p[0]), Math.abs(start[1] - p[1])))
230
- const closest = quadrant[distances.indexOf(Math.min(...distances))]
231
- return pixels.findIndex(i => JSON.stringify(i) === JSON.stringify(closest))
232
- }
233
-
234
205
  export {
235
206
  toTurfGeometry,
236
207
  splitPolygon,
@@ -238,6 +209,5 @@ export {
238
209
  extendLine,
239
210
  isNewCoordinate,
240
211
  isValidClick,
241
- isValidLineClick,
242
- spatialNavigate
212
+ isValidLineClick
243
213
  }
@@ -7,8 +7,7 @@ import {
7
7
  extendLine,
8
8
  isNewCoordinate,
9
9
  isValidClick,
10
- isValidLineClick,
11
- spatialNavigate
10
+ isValidLineClick
12
11
  } from './spatial.js'
13
12
 
14
13
  jest.mock('polygon-splitter', () => jest.fn())
@@ -219,25 +218,3 @@ describe('isValidLineClick', () => {
219
218
  expect(isValidLineClick([[0, 0], [0, 0]])).toBe(false)
220
219
  })
221
220
  })
222
-
223
- describe('spatialNavigate', () => {
224
- const start = [0, 0]
225
- const pixels = [[0, 0], [0, -10], [0, 10], [-10, 0], [10, 0]]
226
-
227
- test.each([
228
- ['ArrowUp', 1],
229
- ['ArrowDown', 2],
230
- ['ArrowLeft', 3],
231
- ['ArrowRight', 4]
232
- ])('finds the nearest pixel for %s', (direction, expectedIndex) => {
233
- expect(spatialNavigate(start, pixels, direction)).toBe(expectedIndex)
234
- })
235
-
236
- test('considers all pixels for an unrecognised direction', () => {
237
- expect(spatialNavigate(start, pixels, 'Tab')).toBe(1)
238
- })
239
-
240
- test('falls back to the start point when no pixel is in the quadrant', () => {
241
- expect(spatialNavigate(start, [[0, 0]], 'ArrowUp')).toBe(0)
242
- })
243
- })