@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,51 @@
1
+ import { useState, useEffect } from 'react'
2
+
3
+ const SET_SPATIAL_LIST = 'map:setspatiallist'
4
+ const SET_SPATIAL_LIST_SUPPRESSED = 'map:setspatiallistsuppressed'
5
+
6
+ /**
7
+ * Subscribes to map:setspatiallist for the listbox item list, and
8
+ * map:setspatiallistsuppressed (any plugin can emit this) to report items as empty while
9
+ * suppressed regardless of what's actually visible — which is what drives <SpatialList>'s own
10
+ * tabIndex/aria-hidden.
11
+ *
12
+ * Items always carry at least { id, label, x?, y? } — SpatialList.jsx/useSpatialListFocus.js
13
+ * only ever read those. Whichever plugin currently owns the list (via spatialListRegistry —
14
+ * interact today, draw's edit-mode vertices in future) may include further fields of its own
15
+ * on each item; those simply ride along unused here. `label` is that same owner's own name for
16
+ * what the list currently represents (e.g. "Map features", or "Shape points" while draw holds
17
+ * an exclusive claim) — see spatialListRegistry.js.
18
+ *
19
+ * @param {object} eventBus
20
+ * @returns {{ items: Array<{ id: string, label: string, x?: number, y?: number }>, multiselectable: boolean, label: string|undefined, focusable: boolean }}
21
+ */
22
+ export function useSpatialListItems (eventBus) {
23
+ const [items, setItems] = useState([])
24
+ const [multiselectable, setMultiselectable] = useState(false)
25
+ const [label, setLabel] = useState(undefined)
26
+ const [focusable, setFocusable] = useState(true)
27
+ const [suppressed, setSuppressed] = useState(false)
28
+
29
+ useEffect(() => {
30
+ if (!eventBus) {
31
+ return undefined
32
+ }
33
+ const handleSetSpatialList = ({ items: next = [], multiselectable: nextMultiselectable = false, label: nextLabel, focusable: nextFocusable = true } = {}) => {
34
+ setItems(next)
35
+ setMultiselectable(nextMultiselectable)
36
+ setLabel(nextLabel)
37
+ setFocusable(nextFocusable)
38
+ }
39
+ const handleSuppressed = ({ suppressed: next = false } = {}) => {
40
+ setSuppressed(next)
41
+ }
42
+ eventBus.on(SET_SPATIAL_LIST, handleSetSpatialList)
43
+ eventBus.on(SET_SPATIAL_LIST_SUPPRESSED, handleSuppressed)
44
+ return () => {
45
+ eventBus.off(SET_SPATIAL_LIST, handleSetSpatialList)
46
+ eventBus.off(SET_SPATIAL_LIST_SUPPRESSED, handleSuppressed)
47
+ }
48
+ }, [eventBus])
49
+
50
+ return { items: suppressed ? [] : items, multiselectable, label, focusable }
51
+ }
@@ -0,0 +1,217 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useSpatialListItems } from './useSpatialListItems.js'
3
+
4
+ const SET_FEATURES = 'map:setspatiallist' // NOSONAR
5
+ const SET_FEATURES_SUPPRESSED = 'map:setspatiallistsuppressed' // NOSONAR
6
+
7
+ const makeEventBus = () => {
8
+ const listeners = {}
9
+ return {
10
+ on: jest.fn((eventName, fn) => { listeners[eventName] = fn }),
11
+ off: jest.fn(),
12
+ emit: (eventName, payload) => listeners[eventName]?.(payload)
13
+ }
14
+ }
15
+
16
+ // ─── useSpatialListItems — initial state ─────────────────────────────────────────
17
+
18
+ describe('useSpatialListItems — initial state', () => {
19
+ it('returns empty items and multiselectable false before any event', () => {
20
+ const { result } = renderHook(() => useSpatialListItems(makeEventBus()))
21
+ expect(result.current.items).toEqual([])
22
+ expect(result.current.multiselectable).toBe(false)
23
+ })
24
+
25
+ it('returns empty items and multiselectable false when eventBus is undefined', () => {
26
+ const { result } = renderHook(() => useSpatialListItems(undefined))
27
+ expect(result.current.items).toEqual([])
28
+ expect(result.current.multiselectable).toBe(false)
29
+ })
30
+ })
31
+
32
+ // ─── useSpatialListItems — event subscription ────────────────────────────────────
33
+
34
+ describe('useSpatialListItems — event subscription', () => {
35
+ it('subscribes to map:setfeatures on mount', () => {
36
+ const eb = makeEventBus()
37
+ renderHook(() => useSpatialListItems(eb))
38
+ expect(eb.on).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
39
+ })
40
+
41
+ it('subscribes to map:setfeaturessuppressed on mount', () => {
42
+ const eb = makeEventBus()
43
+ renderHook(() => useSpatialListItems(eb))
44
+ expect(eb.on).toHaveBeenCalledWith(SET_FEATURES_SUPPRESSED, expect.any(Function))
45
+ })
46
+
47
+ it('unsubscribes on unmount', () => {
48
+ const eb = makeEventBus()
49
+ const { unmount } = renderHook(() => useSpatialListItems(eb))
50
+ unmount()
51
+ expect(eb.off).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
52
+ expect(eb.off).toHaveBeenCalledWith(SET_FEATURES_SUPPRESSED, expect.any(Function))
53
+ })
54
+
55
+ it('does not subscribe when eventBus is undefined', () => {
56
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
57
+ renderHook(() => useSpatialListItems(undefined))
58
+ expect(spy).not.toHaveBeenCalled()
59
+ spy.mockRestore()
60
+ })
61
+ })
62
+
63
+ // ─── useSpatialListItems — items updates ─────────────────────────────────────────
64
+
65
+ describe('useSpatialListItems — items updates', () => {
66
+ it('updates items when map:setfeatures is emitted', () => {
67
+ const eb = makeEventBus()
68
+ const { result } = renderHook(() => useSpatialListItems(eb))
69
+ const items = [{ id: 'a', label: 'Feature A' }, { id: 'b', label: 'Feature B' }]
70
+ act(() => eb.emit(SET_FEATURES, { items }))
71
+ expect(result.current.items).toEqual(items)
72
+ })
73
+
74
+ it('clears items when emitted with an empty array', () => {
75
+ const eb = makeEventBus()
76
+ const { result } = renderHook(() => useSpatialListItems(eb))
77
+ act(() => eb.emit(SET_FEATURES, { items: [{ id: 'a', label: 'A' }] }))
78
+ act(() => eb.emit(SET_FEATURES, { items: [] }))
79
+ expect(result.current.items).toEqual([])
80
+ })
81
+
82
+ it('defaults items to empty array when items key is missing from payload', () => {
83
+ const eb = makeEventBus()
84
+ const { result } = renderHook(() => useSpatialListItems(eb))
85
+ act(() => eb.emit(SET_FEATURES, {}))
86
+ expect(result.current.items).toEqual([])
87
+ })
88
+
89
+ it('defaults items to empty array when the event carries no payload at all', () => {
90
+ const eb = makeEventBus()
91
+ const { result } = renderHook(() => useSpatialListItems(eb))
92
+ act(() => eb.emit(SET_FEATURES))
93
+ expect(result.current.items).toEqual([])
94
+ })
95
+ })
96
+
97
+ // ─── useSpatialListItems — label updates ─────────────────────────────────────────
98
+
99
+ describe('useSpatialListItems — label updates', () => {
100
+ it('returns undefined label before any event', () => {
101
+ const { result } = renderHook(() => useSpatialListItems(makeEventBus()))
102
+ expect(result.current.label).toBeUndefined()
103
+ })
104
+
105
+ it('returns whatever label the current provider declared', () => {
106
+ const eb = makeEventBus()
107
+ const { result } = renderHook(() => useSpatialListItems(eb))
108
+ act(() => eb.emit(SET_FEATURES, { items: [], label: 'Shape points' }))
109
+ expect(result.current.label).toBe('Shape points')
110
+ })
111
+
112
+ it('updates the label when a later event declares a different one', () => {
113
+ const eb = makeEventBus()
114
+ const { result } = renderHook(() => useSpatialListItems(eb))
115
+ act(() => eb.emit(SET_FEATURES, { items: [], label: 'Map features' }))
116
+ act(() => eb.emit(SET_FEATURES, { items: [], label: 'Shape points' }))
117
+ expect(result.current.label).toBe('Shape points')
118
+ })
119
+ })
120
+
121
+ // ─── useSpatialListItems — focusable updates ─────────────────────────────────────
122
+
123
+ describe('useSpatialListItems — focusable updates', () => {
124
+ it('defaults to focusable: true before any event', () => {
125
+ const { result } = renderHook(() => useSpatialListItems(makeEventBus()))
126
+ expect(result.current.focusable).toBe(true)
127
+ })
128
+
129
+ it('defaults to focusable: true when the event omits it', () => {
130
+ const eb = makeEventBus()
131
+ const { result } = renderHook(() => useSpatialListItems(eb))
132
+ act(() => eb.emit(SET_FEATURES, { items: [] }))
133
+ expect(result.current.focusable).toBe(true)
134
+ })
135
+
136
+ it('reflects an explicit focusable: false from the current provider', () => {
137
+ const eb = makeEventBus()
138
+ const { result } = renderHook(() => useSpatialListItems(eb))
139
+ act(() => eb.emit(SET_FEATURES, { items: [], focusable: false }))
140
+ expect(result.current.focusable).toBe(false)
141
+ })
142
+
143
+ it('reverts to true once a later event omits it again (e.g. draw released its claim)', () => {
144
+ const eb = makeEventBus()
145
+ const { result } = renderHook(() => useSpatialListItems(eb))
146
+ act(() => eb.emit(SET_FEATURES, { items: [], focusable: false }))
147
+ act(() => eb.emit(SET_FEATURES, { items: [] }))
148
+ expect(result.current.focusable).toBe(true)
149
+ })
150
+ })
151
+
152
+ // ─── useSpatialListItems — multiselectable updates ───────────────────────────────
153
+
154
+ describe('useSpatialListItems — multiselectable updates', () => {
155
+ it('sets multiselectable true when emitted with multiselectable: true', () => {
156
+ const eb = makeEventBus()
157
+ const { result } = renderHook(() => useSpatialListItems(eb))
158
+ act(() => eb.emit(SET_FEATURES, { items: [], multiselectable: true }))
159
+ expect(result.current.multiselectable).toBe(true)
160
+ })
161
+
162
+ it('defaults multiselectable to false when not present in payload', () => {
163
+ const eb = makeEventBus()
164
+ const { result } = renderHook(() => useSpatialListItems(eb))
165
+ act(() => eb.emit(SET_FEATURES, { items: [] }))
166
+ expect(result.current.multiselectable).toBe(false)
167
+ })
168
+ })
169
+
170
+ // ─── useSpatialListItems — suppression ───────────────────────────────────────────
171
+
172
+ describe('useSpatialListItems — suppression', () => {
173
+ const items = [{ id: 'a', label: 'Feature A' }]
174
+
175
+ it('reports empty items while suppressed, regardless of what was last set', () => {
176
+ const eb = makeEventBus()
177
+ const { result } = renderHook(() => useSpatialListItems(eb))
178
+ act(() => eb.emit(SET_FEATURES, { items }))
179
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
180
+ expect(result.current.items).toEqual([])
181
+ })
182
+
183
+ it('restores the last known items once suppression is lifted', () => {
184
+ const eb = makeEventBus()
185
+ const { result } = renderHook(() => useSpatialListItems(eb))
186
+ act(() => eb.emit(SET_FEATURES, { items }))
187
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
188
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
189
+ expect(result.current.items).toEqual(items)
190
+ })
191
+
192
+ it('defaults suppressed to false when the payload omits it', () => {
193
+ const eb = makeEventBus()
194
+ const { result } = renderHook(() => useSpatialListItems(eb))
195
+ act(() => eb.emit(SET_FEATURES, { items }))
196
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, {}))
197
+ expect(result.current.items).toEqual(items)
198
+ })
199
+
200
+ it('defaults suppressed to false when the event carries no payload at all', () => {
201
+ const eb = makeEventBus()
202
+ const { result } = renderHook(() => useSpatialListItems(eb))
203
+ act(() => eb.emit(SET_FEATURES, { items }))
204
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED))
205
+ expect(result.current.items).toEqual(items)
206
+ })
207
+
208
+ it('a features update received while suppressed is still applied once restored', () => {
209
+ const eb = makeEventBus()
210
+ const { result } = renderHook(() => useSpatialListItems(eb))
211
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
212
+ act(() => eb.emit(SET_FEATURES, { items }))
213
+ expect(result.current.items).toEqual([]) // still suppressed
214
+ act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
215
+ expect(result.current.items).toEqual(items)
216
+ })
217
+ })
@@ -6,6 +6,7 @@ import { createPanelRegistry } from './registry/panelRegistry.js'
6
6
  import { createControlRegistry } from './registry/controlRegistry.js'
7
7
  import { createPluginRegistry } from './registry/pluginRegistry.js'
8
8
  import { createKeyboardShortcutRegistry } from './registry/keyboardShortcutRegistry.js'
9
+ import { createSpatialListRegistry } from './registry/spatialListRegistry.js'
9
10
  import { mergeManifests } from './registry/mergeManifests.js'
10
11
  import { App } from './App.jsx'
11
12
 
@@ -13,13 +14,14 @@ const rootMap = new WeakMap()
13
14
  const mapProviderMap = new WeakMap()
14
15
  const registryMap = new WeakMap()
15
16
 
16
- const getOrCreateRegistries = (rootElement) => {
17
+ const getOrCreateRegistries = (rootElement, eventBus) => {
17
18
  let registries = registryMap.get(rootElement)
18
19
  if (!registries) {
19
20
  const buttonRegistry = createButtonRegistry()
20
21
  const panelRegistry = createPanelRegistry()
21
22
  const controlRegistry = createControlRegistry()
22
23
  const keyboardShortcutRegistry = createKeyboardShortcutRegistry()
24
+ const spatialListRegistry = createSpatialListRegistry({ eventBus })
23
25
  const pluginRegistry = createPluginRegistry({
24
26
  registerButton: buttonRegistry.registerButton,
25
27
  registerPanel: panelRegistry.registerPanel,
@@ -27,7 +29,7 @@ const getOrCreateRegistries = (rootElement) => {
27
29
  registerKeyboardShortcut: keyboardShortcutRegistry.registerKeyboardShortcut
28
30
  })
29
31
 
30
- registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry }
32
+ registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry, spatialListRegistry }
31
33
  registryMap.set(rootElement, registries)
32
34
  }
33
35
  return registries
@@ -73,7 +75,7 @@ export async function initialiseApp (rootElement, {
73
75
  }
74
76
 
75
77
  // Reuse or create registries (persist across app open/close cycles)
76
- const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry } = getOrCreateRegistries(rootElement)
78
+ const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry, spatialListRegistry } = getOrCreateRegistries(rootElement, eventBus)
77
79
  const { registerPlugin } = pluginRegistry
78
80
 
79
81
  // Register provider-supported shortcuts
@@ -124,6 +126,7 @@ export async function initialiseApp (rootElement, {
124
126
  controlRegistry={controlRegistry}
125
127
  pluginRegistry={pluginRegistry}
126
128
  keyboardShortcutRegistry={keyboardShortcutRegistry}
129
+ spatialListRegistry={spatialListRegistry}
127
130
  mapProvider={mapProvider}
128
131
  />)
129
132
 
@@ -50,6 +50,10 @@ jest.mock('./registry/keyboardShortcutRegistry.js', () => ({
50
50
  createKeyboardShortcutRegistry: jest.fn(() => mockRegistries.keyboardShortcutRegistry)
51
51
  }))
52
52
 
53
+ jest.mock('./registry/spatialListRegistry.js', () => ({
54
+ createSpatialListRegistry: jest.fn(() => mockRegistries.spatialListRegistry)
55
+ }))
56
+
53
57
  jest.mock('./registry/mergeManifests.js', () => ({
54
58
  mergeManifests: jest.fn((base, override) => ({ ...base, ...override }))
55
59
  }))
@@ -193,4 +197,11 @@ describe('initialiseApp', () => {
193
197
  const rendered = createRoot.mock.results[0].value.render.mock.calls[0][0]
194
198
  expect(rendered.props.keyboardShortcutRegistry).toBe(mockRegistries.keyboardShortcutRegistry)
195
199
  })
200
+
201
+ test('passes spatialListRegistry through to the rendered App', async () => {
202
+ await initApp()
203
+
204
+ const rendered = createRoot.mock.results[0].value.render.mock.calls[0][0]
205
+ expect(rendered.props.spatialListRegistry).toBe(mockRegistries.spatialListRegistry)
206
+ })
196
207
  })
@@ -11,14 +11,16 @@ import { layoutSlots } from '../renderer/slots'
11
11
  import { SlotRenderer } from '../renderer/SlotRenderer'
12
12
  import { HtmlElementHost } from '../renderer/HtmlElementHost'
13
13
  import { Hints } from '../components/Hints/Hints.jsx'
14
+ import { hasOpenModalPanel } from '../renderer/slotHelpers.js'
14
15
  import { getMapThemeVars } from '../../config/mapTheme.js'
15
16
 
16
17
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
17
18
  // sonarjs/disable-next-line function-name
18
19
  export const Layout = () => {
19
20
  const { id, mapLabel, mapHintText } = useConfig()
20
- const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen } = useApp()
21
+ const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen, openPanels, panelConfig } = useApp()
21
22
  const { mapStyle } = useMap()
23
+ const showModalBackdrop = hasOpenModalPanel(openPanels ?? {}, panelConfig ?? {}, breakpoint)
22
24
 
23
25
  useLayoutMeasurements()
24
26
  useFocusVisible()
@@ -108,7 +110,7 @@ export const Layout = () => {
108
110
  </div>
109
111
  <div className='im-o-app__modal' ref={layoutRefs.modalRef}>
110
112
  <SlotRenderer slot={layoutSlots.MODAL} />
111
- <div className='im-o-app__modal-backdrop' />
113
+ <div className={`im-o-app__modal-backdrop${showModalBackdrop ? ' im-o-app__modal-backdrop--visible' : ''}`} />
112
114
  </div>
113
115
  </div>
114
116
  </div>
@@ -95,6 +95,27 @@ describe('Layout', () => {
95
95
  expect(screen.getByTestId('slot-top-left')).toBeInTheDocument()
96
96
  expect(screen.getByTestId('slot-bottom-right')).toBeInTheDocument()
97
97
  expect(screen.getByTestId('slot-modal')).toBeInTheDocument()
98
+
99
+ const backdrop = root.querySelector('.im-o-app__modal-backdrop')
100
+ expect(backdrop).toBeInTheDocument()
101
+ expect(backdrop).not.toHaveClass('im-o-app__modal-backdrop--visible')
102
+ })
103
+
104
+ test('shows the modal backdrop only while a modal-configured panel is open', () => {
105
+ useApp.mockReturnValue({
106
+ breakpoint: 'mobile',
107
+ interfaceType: 'map',
108
+ preferredColorScheme: 'dark',
109
+ layoutRefs: mockRefs,
110
+ isLayoutReady: true,
111
+ hasExclusiveControl: false,
112
+ isFullscreen: true,
113
+ openPanels: { settings: { props: {} } },
114
+ panelConfig: { settings: { mobile: { modal: true } } }
115
+ })
116
+ render(<Layout />)
117
+ const root = document.getElementById('myApp-im-app')
118
+ expect(root.querySelector('.im-o-app__modal-backdrop')).toHaveClass('im-o-app__modal-backdrop--visible')
98
119
  })
99
120
 
100
121
  test('applies "not-ready" class when layout is not ready', () => {
@@ -363,7 +363,13 @@
363
363
  z-index: 1000;
364
364
  }
365
365
 
366
- .im-c-panel + .im-o-app__modal-backdrop {
366
+ // Visibility is JS-driven (Layout.jsx) since multiple panels can share this slot.
367
+ .im-o-app__modal-backdrop {
368
+ display: none;
369
+ }
370
+
371
+ .im-o-app__modal-backdrop--visible {
372
+ display: block;
367
373
  position: absolute;
368
374
  top: 0;
369
375
  left: 0;
@@ -19,8 +19,8 @@ const _setProviderSupportedShortcuts = (state, ids = []) => {
19
19
 
20
20
  const _getKeyboardShortcuts = (state, appConfig = {}) => {
21
21
  const filteredCore = coreShortcuts.filter(s => {
22
- // Must be supported by provider
23
- if (!state.providerSupportedIds.has(s.id)) {
22
+ // Must be supported by the map provider, unless the shortcut is map-provider-agnostic (no capability dependency, so no adapter needs to declare it).
23
+ if (!s.mapProviderAgnostic && !state.providerSupportedIds.has(s.id)) {
24
24
  return false
25
25
  }
26
26
  // Check requiredConfig - all specified config values must be truthy
@@ -93,6 +93,22 @@ describe('createKeyboardShortcutRegistry', () => {
93
93
  ])
94
94
  })
95
95
 
96
+ test('a mapProviderAgnostic core shortcut is included even when no provider declares support for it', () => {
97
+ jest.resetModules()
98
+ jest.doMock('../controls/keyboardShortcuts.js', () => ({
99
+ coreShortcuts: [
100
+ { id: 'ordinary', description: 'Ordinary' },
101
+ { id: 'agnostic', description: 'Agnostic', mapProviderAgnostic: true }
102
+ ]
103
+ }))
104
+ const registry = require('./keyboardShortcutRegistry.js').createKeyboardShortcutRegistry()
105
+ registry.setProviderSupportedShortcuts([]) // no provider support declared at all
106
+
107
+ expect(registry.getKeyboardShortcuts()).toEqual([
108
+ { id: 'agnostic', description: 'Agnostic', mapProviderAgnostic: true }
109
+ ])
110
+ })
111
+
96
112
  test('two registry instances are fully isolated from each other', () => {
97
113
  // This is the multi-map-on-a-page scenario: a shortcut registered by one map
98
114
  // (e.g. a plugin) must never leak into another map's help panel, and each map's
@@ -73,9 +73,22 @@ export function createPluginRegistry ({ registerButton, registerPanel, registerC
73
73
  registeredPlugins.length = 0
74
74
  }
75
75
 
76
+ /**
77
+ * Look up another registered plugin's instance by id. Returns `undefined`
78
+ * if not registered; found doesn't mean ready to call — see services.getPlugin
79
+ * in docs/api/context.md.
80
+ *
81
+ * @param {string} id - Plugin id, e.g. `'draw'`.
82
+ * @returns {object|undefined}
83
+ */
84
+ function getPlugin (id) {
85
+ return registeredPlugins.find(plugin => plugin.id === id)?._originalPlugin
86
+ }
87
+
76
88
  return {
77
89
  registeredPlugins,
78
90
  registerPlugin,
91
+ getPlugin,
79
92
  clear
80
93
  }
81
94
  }
@@ -185,6 +185,21 @@ describe('pluginRegistry', () => {
185
185
  })
186
186
  })
187
187
 
188
+ describe('getPlugin', () => {
189
+ it('returns the _originalPlugin reference for a registered plugin id', () => {
190
+ const originalPlugin = { newPolygon: jest.fn() }
191
+ const plugin = { id: 'draw', config: {}, manifest: {}, _originalPlugin: originalPlugin }
192
+
193
+ pluginRegistry.registerPlugin(plugin)
194
+
195
+ expect(pluginRegistry.getPlugin('draw')).toBe(originalPlugin)
196
+ })
197
+
198
+ it('returns undefined for an id that is not registered', () => {
199
+ expect(pluginRegistry.getPlugin('does-not-exist')).toBeUndefined()
200
+ })
201
+ })
202
+
188
203
  it('clears all registered plugins', () => {
189
204
  const pluginA = { id: 'A', config: {}, manifest: {} }
190
205
  const pluginB = { id: 'B', config: {}, manifest: {} }
@@ -0,0 +1,104 @@
1
+ import { EVENTS as events } from '../../config/events.js'
2
+
3
+ /**
4
+ * Central registry for plugins that contribute items to the shared spatial list (see
5
+ * SpatialList.jsx). Plugins register a live item provider — `{ getItems, exclusive? }` —
6
+ * instead of emitting the list event themselves, so exclusive takeover (draw mid-edit) and
7
+ * additive participation (interact's features/markers) both work safely with any number of
8
+ * contributors.
9
+ *
10
+ * `getItems()` returns `{ items, multiselectable, label, focusable }`, called fresh on every
11
+ * recompute rather than snapshotted at registration. `label` becomes the listbox's aria-label;
12
+ * `focusable: false` (draw's vertex/midpoint items) keeps every item out of the Tab order —
13
+ * see SpatialList.jsx. `exclusive: true` claims sole ownership while held (see
14
+ * claimExclusive/releaseExclusive); everything else is additive and gets concatenated
15
+ * together. Providers call notifyItemsChanged whenever their own items would differ — the
16
+ * registry never polls, it only recomputes in response to a caller flagging a change.
17
+ */
18
+ export function createSpatialListRegistry ({ eventBus }) {
19
+ const providers = new Map() // pluginId -> { getItems, exclusive }
20
+ let exclusiveOwner = null
21
+
22
+ const recompute = () => {
23
+ let items = []
24
+ let multiselectable = false
25
+ let label
26
+ let focusable
27
+
28
+ if (exclusiveOwner && providers.has(exclusiveOwner)) {
29
+ const result = providers.get(exclusiveOwner).getItems() ?? {}
30
+ items = result.items ?? []
31
+ multiselectable = !!result.multiselectable
32
+ label = result.label
33
+ focusable = result.focusable
34
+ } else {
35
+ // Exclusive-capable providers only ever contribute while actually holding the
36
+ // claim (the branch above) — not just whenever nobody happens to hold it.
37
+ providers.forEach((provider) => {
38
+ if (provider.exclusive) { return }
39
+ const result = provider.getItems() ?? {}
40
+ items = items.concat(result.items ?? [])
41
+ multiselectable = multiselectable || !!result.multiselectable
42
+ // First additive provider (in registration order) with a label/focusable value
43
+ // wins — there's no principled way to merge two different values, and today
44
+ // only interact ever contributes additively, so this is just "whatever it
45
+ // declares".
46
+ label = label ?? result.label
47
+ focusable = focusable ?? result.focusable
48
+ })
49
+ }
50
+
51
+ eventBus.emit(events.MAP_SET_SPATIAL_LIST, { items, multiselectable, label, focusable })
52
+ }
53
+
54
+ function registerItemProvider (pluginId, { getItems, exclusive = false }) {
55
+ providers.set(pluginId, { getItems, exclusive })
56
+ recompute()
57
+ }
58
+
59
+ function unregisterItemProvider (pluginId) {
60
+ providers.delete(pluginId)
61
+ if (exclusiveOwner === pluginId) {
62
+ exclusiveOwner = null
63
+ }
64
+ recompute()
65
+ }
66
+
67
+ // Mirrors mapProvider.activeMoveTarget's single-claim contract (MapControls.jsx),
68
+ // generalised to however many participants are registered rather than always
69
+ // exactly two — only one exclusive claim can be held at a time, and holding it
70
+ // means every other registered provider is ignored until it's released.
71
+ function claimExclusive (pluginId) {
72
+ exclusiveOwner = pluginId
73
+ recompute()
74
+ }
75
+
76
+ function releaseExclusive (pluginId) {
77
+ if (exclusiveOwner === pluginId) {
78
+ exclusiveOwner = null
79
+ recompute()
80
+ }
81
+ }
82
+
83
+ function notifyItemsChanged (pluginId) {
84
+ if (!providers.has(pluginId)) { return }
85
+ // If someone else holds the exclusive claim, this provider isn't feeding the
86
+ // list right now regardless of what changed for it — nothing to recompute.
87
+ if (exclusiveOwner && exclusiveOwner !== pluginId) { return }
88
+ recompute()
89
+ }
90
+
91
+ function clear () {
92
+ providers.clear()
93
+ exclusiveOwner = null
94
+ }
95
+
96
+ return {
97
+ registerItemProvider,
98
+ unregisterItemProvider,
99
+ claimExclusive,
100
+ releaseExclusive,
101
+ notifyItemsChanged,
102
+ clear
103
+ }
104
+ }