@defra/interactive-map 0.0.43-alpha → 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 (159) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +16 -8
  7. package/docs/plugins/draw.md +1 -1
  8. package/package.json +1 -1
  9. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  11. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  12. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  13. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  14. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  15. package/plugins/datasets/dist/css/index.css +1 -1
  16. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  17. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  18. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  19. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  20. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  21. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  22. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  23. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  24. package/plugins/draw/dist/esm/index.js +1 -1
  25. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  28. package/plugins/draw/dist/umd/index.js +1 -1
  29. package/plugins/draw/src/DrawInit.jsx +31 -26
  30. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  31. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  32. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  33. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  34. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  35. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  36. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  37. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  38. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +8 -2
  39. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +35 -0
  40. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  41. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  42. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  43. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  44. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  45. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  46. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  47. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  48. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  49. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  51. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  52. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  53. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  54. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  55. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  56. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  57. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  58. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  59. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  60. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  61. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  62. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  63. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  64. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  65. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  66. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  67. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  68. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  69. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  70. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +31 -17
  71. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  72. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  73. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  74. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  75. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  76. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  77. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  78. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  79. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  80. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +32 -57
  81. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +14 -13
  82. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  83. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  84. package/plugins/draw/src/events.js +4 -4
  85. package/plugins/draw/src/events.test.js +1 -1
  86. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  87. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  88. package/plugins/draw/src/utils/spatial.js +1 -31
  89. package/plugins/draw/src/utils/spatial.test.js +1 -24
  90. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  92. package/plugins/interact/dist/umd/index.js +1 -1
  93. package/plugins/interact/src/InteractInit.jsx +2 -2
  94. package/plugins/interact/src/InteractInit.test.js +3 -3
  95. package/plugins/interact/src/events.js +5 -0
  96. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  97. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  98. package/plugins/interact/src/hooks/useCrossHairVisibility.js +4 -4
  99. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  100. package/plugins/interact/src/hooks/{useMapItemList.js → useSpatialList.js} +87 -52
  101. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +152 -71
  102. package/plugins/interact/src/manifest.js +12 -1
  103. package/plugins/interact/src/manifest.test.js +20 -0
  104. package/plugins/interact/src/utils/spatial.js +1 -1
  105. package/providers/beta/openlayers/src/utils/queryFeatures.js +1 -1
  106. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  107. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  108. package/providers/maplibre/dist/umd/index.js +1 -1
  109. package/providers/maplibre/src/utils/labels.js +2 -2
  110. package/providers/maplibre/src/utils/labels.test.js +5 -5
  111. package/providers/maplibre/src/utils/spatial.js +0 -57
  112. package/providers/maplibre/src/utils/spatial.test.js +0 -27
  113. package/src/App/components/CrossHair/CrossHair.jsx +10 -4
  114. package/src/App/components/CrossHair/CrossHair.module.scss +16 -12
  115. package/src/App/components/CrossHair/CrossHair.test.jsx +9 -4
  116. package/src/App/components/Hints/Hints.jsx +3 -3
  117. package/src/App/components/Hints/Hints.test.jsx +3 -3
  118. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  119. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  120. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  121. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  122. package/src/App/components/Markers/Markers.jsx +2 -2
  123. package/src/App/components/Markers/Markers.test.jsx +3 -3
  124. package/src/App/components/Markers/SymbolMarker.test.jsx +2 -2
  125. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  126. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  127. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +3 -3
  128. package/src/App/components/Viewport/{Features.test.jsx → SpatialList.test.jsx} +60 -43
  129. package/src/App/components/Viewport/Viewport.jsx +20 -10
  130. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  131. package/src/App/hooks/useCrossHairAPI.js +5 -5
  132. package/src/App/hooks/useCrossHairAPI.test.js +5 -5
  133. package/src/App/hooks/useHintsAPI.js +5 -5
  134. package/src/App/hooks/useSpatialListFocus.js +296 -0
  135. package/src/App/hooks/{useFeatureFocus.test.js → useSpatialListFocus.test.js} +318 -163
  136. package/src/App/hooks/useSpatialListItems.js +51 -0
  137. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  138. package/src/App/initialiseApp.js +6 -3
  139. package/src/App/initialiseApp.test.js +11 -0
  140. package/src/App/registry/spatialListRegistry.js +104 -0
  141. package/src/App/registry/spatialListRegistry.test.js +218 -0
  142. package/src/App/store/AppProvider.jsx +3 -2
  143. package/src/config/appConfig.js +14 -14
  144. package/src/config/appConfig.test.js +19 -19
  145. package/src/config/defaults.js +2 -2
  146. package/src/config/events.js +8 -8
  147. package/src/scss/main.scss +2 -2
  148. package/src/test-utils.js +8 -0
  149. package/src/types.js +11 -9
  150. package/src/utils/findNearestItemInDirection.js +29 -0
  151. package/src/utils/findNearestItemInDirection.test.js +67 -0
  152. package/src/utils/globalAltShortcuts.js +12 -0
  153. package/src/utils/globalAltShortcuts.test.js +27 -0
  154. package/src/utils/spatialNavigate.js +49 -0
  155. package/src/utils/spatialNavigate.test.js +42 -0
  156. package/src/App/components/Viewport/Features.jsx +0 -49
  157. package/src/App/hooks/useFeatureFocus.js +0 -257
  158. package/src/App/hooks/useFeatureItems.js +0 -39
  159. 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
  })
@@ -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
+ }
@@ -0,0 +1,218 @@
1
+ import { createSpatialListRegistry } from './spatialListRegistry.js'
2
+
3
+ const SET_FEATURES = 'map:setspatiallist'
4
+
5
+ const makeEventBus = () => ({ emit: jest.fn() })
6
+
7
+ const lastEmit = (eventBus) => eventBus.emit.mock.calls.at(-1)[1]
8
+
9
+ describe('createSpatialListRegistry', () => {
10
+ test('registering a provider recomputes and emits its items immediately', () => {
11
+ const eventBus = makeEventBus()
12
+ const registry = createSpatialListRegistry({ eventBus })
13
+ registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }], multiselectable: false }) })
14
+ expect(eventBus.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [{ id: '1' }], multiselectable: false })
15
+ })
16
+
17
+ test('concatenates items from every registered provider when no exclusive claim is held', () => {
18
+ const eventBus = makeEventBus()
19
+ const registry = createSpatialListRegistry({ eventBus })
20
+ registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }) })
21
+ registry.registerItemProvider('b', { getItems: () => ({ items: [{ id: '2' }] }) })
22
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: '1' }, { id: '2' }], multiselectable: false })
23
+ })
24
+
25
+ test('multiselectable is true if any additive provider wants it', () => {
26
+ const eventBus = makeEventBus()
27
+ const registry = createSpatialListRegistry({ eventBus })
28
+ registry.registerItemProvider('a', { getItems: () => ({ items: [], multiselectable: false }) })
29
+ registry.registerItemProvider('b', { getItems: () => ({ items: [], multiselectable: true }) })
30
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: true })
31
+ })
32
+
33
+ test('getItems is called fresh on every recompute, not snapshotted at registration', () => {
34
+ const eventBus = makeEventBus()
35
+ const registry = createSpatialListRegistry({ eventBus })
36
+ let current = [{ id: 'first' }]
37
+ registry.registerItemProvider('a', { getItems: () => ({ items: current }) })
38
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'first' }], multiselectable: false })
39
+
40
+ current = [{ id: 'second' }]
41
+ registry.notifyItemsChanged('a')
42
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'second' }], multiselectable: false })
43
+ })
44
+
45
+ test('unregistering a provider removes its items from the next recompute', () => {
46
+ const eventBus = makeEventBus()
47
+ const registry = createSpatialListRegistry({ eventBus })
48
+ registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }) })
49
+ registry.registerItemProvider('b', { getItems: () => ({ items: [{ id: '2' }] }) })
50
+ registry.unregisterItemProvider('a')
51
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: '2' }], multiselectable: false })
52
+ })
53
+
54
+ test('notifyItemsChanged for an unregistered id is a no-op', () => {
55
+ const eventBus = makeEventBus()
56
+ const registry = createSpatialListRegistry({ eventBus })
57
+ registry.notifyItemsChanged('ghost')
58
+ expect(eventBus.emit).not.toHaveBeenCalled()
59
+ })
60
+
61
+ describe('exclusive claims', () => {
62
+ test('claiming exclusive ownership excludes every other provider\'s items', () => {
63
+ const eventBus = makeEventBus()
64
+ const registry = createSpatialListRegistry({ eventBus })
65
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
66
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
67
+
68
+ registry.claimExclusive('draw')
69
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'vertex-1' }], multiselectable: false })
70
+ })
71
+
72
+ test('releasing the exclusive claim reverts to the additive merge of everyone else', () => {
73
+ const eventBus = makeEventBus()
74
+ const registry = createSpatialListRegistry({ eventBus })
75
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
76
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
77
+ registry.claimExclusive('draw')
78
+
79
+ registry.releaseExclusive('draw')
80
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'feature-1' }], multiselectable: false })
81
+ })
82
+
83
+ test('releaseExclusive from a provider that does not currently hold the claim is a no-op', () => {
84
+ const eventBus = makeEventBus()
85
+ const registry = createSpatialListRegistry({ eventBus })
86
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
87
+ eventBus.emit.mockClear()
88
+
89
+ registry.releaseExclusive('draw') // never claimed it
90
+ expect(eventBus.emit).not.toHaveBeenCalled()
91
+ })
92
+
93
+ test('a non-exclusive-owning provider\'s own changes are ignored while someone else holds the claim', () => {
94
+ const eventBus = makeEventBus()
95
+ const registry = createSpatialListRegistry({ eventBus })
96
+ let interactItems = [{ id: 'feature-1' }]
97
+ registry.registerItemProvider('interact', { getItems: () => ({ items: interactItems }) })
98
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
99
+ registry.claimExclusive('draw')
100
+ eventBus.emit.mockClear()
101
+
102
+ interactItems = [{ id: 'feature-2' }] // changed while draw holds exclusive ownership
103
+ registry.notifyItemsChanged('interact')
104
+ expect(eventBus.emit).not.toHaveBeenCalled()
105
+ })
106
+
107
+ test('unregistering the current exclusive owner clears the claim and reverts to additive', () => {
108
+ const eventBus = makeEventBus()
109
+ const registry = createSpatialListRegistry({ eventBus })
110
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
111
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
112
+ registry.claimExclusive('draw')
113
+
114
+ registry.unregisterItemProvider('draw')
115
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'feature-1' }], multiselectable: false })
116
+ })
117
+ })
118
+
119
+ describe('label', () => {
120
+ test('passes through the exclusive claimant\'s own label while its claim is held', () => {
121
+ const eventBus = makeEventBus()
122
+ const registry = createSpatialListRegistry({ eventBus })
123
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [], label: 'Map features' }) })
124
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [], label: 'Shape points' }), exclusive: true })
125
+
126
+ registry.claimExclusive('draw')
127
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'Shape points' })
128
+ })
129
+
130
+ test('uses the first additive provider\'s label (in registration order) when more than one contributes', () => {
131
+ const eventBus = makeEventBus()
132
+ const registry = createSpatialListRegistry({ eventBus })
133
+ registry.registerItemProvider('a', { getItems: () => ({ items: [], label: 'First' }) })
134
+ registry.registerItemProvider('b', { getItems: () => ({ items: [], label: 'Second' }) })
135
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'First' })
136
+ })
137
+
138
+ test('falls back to a later provider\'s label when an earlier one declares none', () => {
139
+ const eventBus = makeEventBus()
140
+ const registry = createSpatialListRegistry({ eventBus })
141
+ registry.registerItemProvider('a', { getItems: () => ({ items: [] }) }) // no label
142
+ registry.registerItemProvider('b', { getItems: () => ({ items: [], label: 'Second' }) })
143
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'Second' })
144
+ })
145
+
146
+ test('label is undefined when nothing is registered', () => {
147
+ const eventBus = makeEventBus()
148
+ const registry = createSpatialListRegistry({ eventBus })
149
+ registry.registerItemProvider('a', { getItems: () => ({ items: [] }) })
150
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
151
+ })
152
+ })
153
+
154
+ describe('focusable', () => {
155
+ test('passes through the exclusive claimant\'s own focusable: false while its claim is held', () => {
156
+ const eventBus = makeEventBus()
157
+ const registry = createSpatialListRegistry({ eventBus })
158
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [] }) }) // focusable defaults true
159
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [], focusable: false }), exclusive: true })
160
+
161
+ registry.claimExclusive('draw')
162
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, focusable: false })
163
+ })
164
+
165
+ test('reverts to undefined (consumer default: focusable) once the claim is released', () => {
166
+ const eventBus = makeEventBus()
167
+ const registry = createSpatialListRegistry({ eventBus })
168
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [] }) })
169
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [], focusable: false }), exclusive: true })
170
+ registry.claimExclusive('draw')
171
+
172
+ registry.releaseExclusive('draw')
173
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
174
+ })
175
+
176
+ test('uses the first additive provider\'s explicit focusable value when more than one contributes', () => {
177
+ const eventBus = makeEventBus()
178
+ const registry = createSpatialListRegistry({ eventBus })
179
+ registry.registerItemProvider('a', { getItems: () => ({ items: [] }) }) // no explicit value
180
+ registry.registerItemProvider('b', { getItems: () => ({ items: [], focusable: false }) })
181
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, focusable: false })
182
+ })
183
+
184
+ test('focusable is undefined when nothing declares it', () => {
185
+ const eventBus = makeEventBus()
186
+ const registry = createSpatialListRegistry({ eventBus })
187
+ registry.registerItemProvider('a', { getItems: () => ({ items: [] }) })
188
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
189
+ })
190
+ })
191
+
192
+ test('clear removes every provider and any exclusive claim', () => {
193
+ const eventBus = makeEventBus()
194
+ const registry = createSpatialListRegistry({ eventBus })
195
+ registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }), exclusive: true })
196
+ registry.claimExclusive('a')
197
+
198
+ registry.clear()
199
+ eventBus.emit.mockClear()
200
+ registry.notifyItemsChanged('a') // 'a' is no longer registered
201
+ expect(eventBus.emit).not.toHaveBeenCalled()
202
+ })
203
+
204
+ test('a provider whose getItems returns nothing contributes no items and does not throw', () => {
205
+ const eventBus = makeEventBus()
206
+ const registry = createSpatialListRegistry({ eventBus })
207
+ expect(() => registry.registerItemProvider('a', { getItems: () => undefined })).not.toThrow()
208
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
209
+ })
210
+
211
+ test('an exclusive provider whose getItems returns nothing contributes no items while holding the claim', () => {
212
+ const eventBus = makeEventBus()
213
+ const registry = createSpatialListRegistry({ eventBus })
214
+ registry.registerItemProvider('draw', { getItems: () => undefined, exclusive: true })
215
+ expect(() => registry.claimExclusive('draw')).not.toThrow()
216
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
217
+ })
218
+ })