@defra/interactive-map 0.0.22-alpha → 0.0.23-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 (190) 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/button-definition.md +39 -0
  7. package/docs/api/context.md +4 -4
  8. package/docs/api/map-style-config.md +11 -2
  9. package/docs/api/marker-config.md +38 -7
  10. package/docs/api/panel-definition.md +7 -3
  11. package/docs/api/symbol-config.md +10 -26
  12. package/docs/api/symbol-registry.md +28 -13
  13. package/docs/api.md +24 -2
  14. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  15. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  16. package/docs/examples/index.mdx +12 -0
  17. package/docs/examples/toggle-marker-label.mdx +89 -0
  18. package/docs/plugins/interact.md +4 -11
  19. package/docusaurus.config.cjs +2 -1
  20. package/package.json +8 -8
  21. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  22. package/plugins/beta/datasets/dist/css/index.css +2 -2
  23. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  25. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  28. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  29. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  30. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  31. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  32. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  33. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  34. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  35. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  36. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  37. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  38. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  39. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  40. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  41. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  42. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  43. package/plugins/beta/use-location/src/manifest.js +1 -0
  44. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  45. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  46. package/plugins/interact/dist/umd/index.js +1 -1
  47. package/plugins/interact/src/InteractInit.jsx +23 -55
  48. package/plugins/interact/src/InteractInit.test.js +58 -80
  49. package/plugins/interact/src/defaults.js +1 -3
  50. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  51. package/plugins/interact/src/hooks/useAttachEvents.test.js +65 -0
  52. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  53. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  54. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  55. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  56. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  57. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  58. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  59. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  60. package/plugins/interact/src/index.js +0 -2
  61. package/plugins/interact/src/index.test.js +0 -2
  62. package/plugins/interact/src/manifest.js +37 -6
  63. package/plugins/interact/src/reducer.js +8 -4
  64. package/plugins/interact/src/reducer.test.js +18 -3
  65. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  66. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  67. package/plugins/interact/src/utils/spatial.js +0 -15
  68. package/plugins/interact/src/utils/spatial.test.js +1 -13
  69. package/plugins/search/dist/css/index.css +1 -1
  70. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  71. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  72. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  73. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  74. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  75. package/plugins/search/src/search.scss +1 -0
  76. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  77. package/providers/beta/esri/src/esriProvider.js +4 -0
  78. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  79. package/providers/maplibre/dist/esm/index.js +1 -1
  80. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  81. package/providers/maplibre/dist/umd/index.js +1 -1
  82. package/providers/maplibre/src/index.js +1 -1
  83. package/providers/maplibre/src/index.test.js +9 -14
  84. package/providers/maplibre/src/mapEvents.js +11 -5
  85. package/providers/maplibre/src/mapEvents.test.js +14 -3
  86. package/providers/maplibre/src/maplibreProvider.js +18 -11
  87. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  88. package/providers/maplibre/src/utils/highlightFeatures.js +102 -57
  89. package/providers/maplibre/src/utils/highlightFeatures.test.js +162 -51
  90. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  91. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  92. package/providers/maplibre/src/utils/patternImages.js +10 -12
  93. package/providers/maplibre/src/utils/patternImages.test.js +145 -167
  94. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  95. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  96. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  97. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  98. package/src/App/components/Actions/Actions.module.scss +1 -0
  99. package/src/App/components/Actions/Actions.test.jsx +17 -4
  100. package/src/App/components/Hints/Hints.jsx +52 -0
  101. package/src/App/components/Hints/Hints.module.scss +37 -0
  102. package/src/App/components/Hints/Hints.test.jsx +104 -0
  103. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  104. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  105. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  106. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  107. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  108. package/src/App/components/Markers/Markers.jsx +44 -75
  109. package/src/App/components/Markers/Markers.module.scss +58 -0
  110. package/src/App/components/Markers/Markers.test.jsx +90 -113
  111. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  112. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  113. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  114. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  115. package/src/App/components/Panel/Panel.jsx +1 -1
  116. package/src/App/components/Panel/Panel.module.scss +1 -0
  117. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  118. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  119. package/src/App/components/Tabs/Tabs.jsx +68 -0
  120. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  121. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  122. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  123. package/src/App/components/Viewport/Features.jsx +35 -0
  124. package/src/App/components/Viewport/Features.module.scss +31 -0
  125. package/src/App/components/Viewport/Features.test.jsx +124 -0
  126. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  127. package/src/App/components/Viewport/Viewport.jsx +35 -29
  128. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  129. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  130. package/src/App/controls/keyboardActions.js +9 -3
  131. package/src/App/controls/keyboardActions.test.js +122 -87
  132. package/src/App/controls/keyboardShortcuts.js +1 -14
  133. package/src/App/hooks/useFeatureFocus.js +199 -0
  134. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  135. package/src/App/hooks/useFeatureItems.js +30 -0
  136. package/src/App/hooks/useFeatureItems.test.js +97 -0
  137. package/src/App/hooks/useFocusVisible.js +4 -7
  138. package/src/App/hooks/useFocusVisible.test.js +6 -6
  139. package/src/App/hooks/useKeyboardHint.js +9 -34
  140. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  141. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  142. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  143. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  144. package/src/App/hooks/useLayoutMeasurements.test.js +30 -0
  145. package/src/App/hooks/useMarkerCursor.js +33 -0
  146. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  147. package/src/App/hooks/useMarkersAPI.js +32 -9
  148. package/src/App/hooks/useMarkersAPI.test.js +162 -94
  149. package/src/App/layout/Layout.jsx +2 -0
  150. package/src/App/layout/Layout.test.jsx +3 -0
  151. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  152. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  153. package/src/App/renderer/mapButtons.js +5 -1
  154. package/src/App/renderer/mapButtons.test.js +37 -1
  155. package/src/App/store/ServiceProvider.jsx +4 -1
  156. package/src/App/store/ServiceProvider.test.jsx +11 -0
  157. package/src/App/store/appActionsMap.js +5 -1
  158. package/src/App/store/appActionsMap.test.js +13 -0
  159. package/src/App/store/appReducer.js +1 -0
  160. package/src/App/store/mapActionsMap.js +2 -1
  161. package/src/InteractiveMap/InteractiveMap.js +12 -0
  162. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  163. package/src/config/appConfig.js +3 -1
  164. package/src/config/appConfig.test.js +4 -0
  165. package/src/config/defaults.js +1 -1
  166. package/src/config/events.js +8 -0
  167. package/src/config/mapTheme.js +8 -4
  168. package/src/config/symbolConfig.js +23 -16
  169. package/src/scss/main.scss +3 -0
  170. package/src/scss/tools/_border-focus.scss +0 -1
  171. package/src/services/eventBus.js +15 -0
  172. package/src/services/eventBus.test.js +11 -0
  173. package/src/services/hints.js +47 -0
  174. package/src/services/hints.test.js +110 -0
  175. package/src/services/patternRegistry.js +69 -1
  176. package/src/services/patternRegistry.test.js +112 -1
  177. package/src/services/reverseGeocode.js +4 -0
  178. package/src/services/reverseGeocode.test.js +11 -1
  179. package/src/services/symbolRegistry.js +152 -17
  180. package/src/services/symbolRegistry.test.js +141 -31
  181. package/src/types.js +71 -14
  182. package/src/utils/constrainKeyboardFocus.js +5 -5
  183. package/src/utils/detectInterfaceType.js +27 -5
  184. package/src/utils/detectInterfaceType.test.js +29 -3
  185. package/src/utils/patternUtils.js +4 -67
  186. package/src/utils/patternUtils.test.js +1 -123
  187. package/src/utils/symbolUtils.js +11 -13
  188. package/src/utils/symbolUtils.test.js +18 -26
  189. package/plugins/interact/dist/css/index.css +0 -1
  190. package/plugins/interact/src/interact.scss +0 -24
@@ -0,0 +1,584 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useMapItemList } from './useMapItemList.js'
3
+
4
+ const MARKER_LABEL = 'Marker One'
5
+
6
+ const MOVE_END = 'map:moveend'
7
+ const DATA_CHANGE = 'map:datachange'
8
+ const SET_FEATURES = 'map:setfeatures'
9
+ const SET_ACTIVE = 'map:setactivefeature'
10
+ const CONFIRM = 'map:selectfeature'
11
+
12
+ const makeEventBus = () => {
13
+ const listeners = {}
14
+ return {
15
+ on: jest.fn((e, fn) => { listeners[e] = fn }),
16
+ off: jest.fn(),
17
+ emit: jest.fn((e, payload) => listeners[e]?.(payload))
18
+ }
19
+ }
20
+
21
+ const makeMarkerEl = ({ inViewport = true } = {}) => {
22
+ const el = document.createElement('div')
23
+ const container = document.createElement('div')
24
+ container.className = 'im-c-viewport__markers'
25
+ container.appendChild(el)
26
+ document.body.appendChild(container)
27
+
28
+ const containerRect = { left: 0, top: 0, right: 500, bottom: 500 }
29
+ const markerRect = inViewport
30
+ ? { left: 100, top: 100, right: 150, bottom: 150 }
31
+ : { left: 600, top: 600, right: 650, bottom: 650 }
32
+
33
+ jest.spyOn(container, 'getBoundingClientRect').mockReturnValue(containerRect)
34
+ jest.spyOn(el, 'getBoundingClientRect').mockReturnValue(markerRect)
35
+
36
+ return { el, container }
37
+ }
38
+
39
+ const makeMarkers = (overrides = []) => {
40
+ const markerRefs = new Map()
41
+ return { items: overrides, markerRefs }
42
+ }
43
+
44
+ const makeMapProvider = (features = []) => ({
45
+ getVisibleFeatures: jest.fn(() => features)
46
+ })
47
+
48
+ const setup = ({ interactionModes = [], markers, layers = [], mapProvider, eventBus, dispatch, multiSelect = false } = {}) => {
49
+ const eb = eventBus ?? makeEventBus()
50
+ const mp = mapProvider ?? makeMapProvider()
51
+ const dp = dispatch ?? jest.fn()
52
+ const { result, unmount } = renderHook(() => useMapItemList({
53
+ mapState: { markers: markers ?? makeMarkers() },
54
+ pluginState: { interactionModes, layers, dispatch: dp, multiSelect },
55
+ services: { eventBus: eb },
56
+ mapProvider: mp
57
+ }))
58
+ return { eb, mp, dp, result, unmount }
59
+ }
60
+
61
+ // ─── useMapItemList — lifecycle ──────────────────────────────────────────
62
+
63
+ describe('useMapItemList — lifecycle', () => {
64
+ it('subscribes to map:moveend on mount', () => {
65
+ const { eb } = setup()
66
+ expect(eb.on).toHaveBeenCalledWith(MOVE_END, expect.any(Function))
67
+ })
68
+
69
+ it('subscribes to map:datachange on mount', () => {
70
+ const { eb } = setup()
71
+ expect(eb.on).toHaveBeenCalledWith(DATA_CHANGE, expect.any(Function))
72
+ })
73
+
74
+ it('unsubscribes from map:moveend on unmount', () => {
75
+ const { eb, unmount } = setup()
76
+ unmount()
77
+ expect(eb.off).toHaveBeenCalledWith(MOVE_END, expect.any(Function))
78
+ })
79
+
80
+ it('unsubscribes from map:datachange on unmount', () => {
81
+ const { eb, unmount } = setup()
82
+ unmount()
83
+ expect(eb.off).toHaveBeenCalledWith(DATA_CHANGE, expect.any(Function))
84
+ })
85
+ })
86
+
87
+ // ─── useMapItemList — initial population ─────────────────────────────────
88
+
89
+ describe('useMapItemList — initial population', () => {
90
+ afterEach(() => { document.body.innerHTML = '' })
91
+
92
+ it('emits visible markers immediately on mount without waiting for moveend', () => {
93
+ const { el, container } = makeMarkerEl({ inViewport: true })
94
+ const markers = makeMarkers([{ id: 'm1', label: MARKER_LABEL, symbol: 'pin', isVisible: true }])
95
+ markers.markerRefs.set('m1', el)
96
+
97
+ const { eb } = setup({ interactionModes: ['selectMarker'], markers })
98
+
99
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
100
+ items: [{ id: 'm1', label: MARKER_LABEL }], multiselectable: false
101
+ })
102
+ container.remove()
103
+ })
104
+
105
+ it('emits empty items immediately when no markers are in viewport', () => {
106
+ const { eb } = setup({ interactionModes: ['selectMarker'] })
107
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
108
+ })
109
+ })
110
+
111
+ // ─── useMapItemList — datachange trigger ─────────────────────────────────
112
+
113
+ describe('useMapItemList — datachange trigger', () => {
114
+ afterEach(() => { document.body.innerHTML = '' })
115
+
116
+ it('re-emits items on map:datachange the same as map:moveend', () => {
117
+ const { el, container } = makeMarkerEl({ inViewport: true })
118
+ const markers = makeMarkers([{ id: 'm1', label: MARKER_LABEL, symbol: 'pin', isVisible: true }])
119
+ markers.markerRefs.set('m1', el)
120
+
121
+ const { eb } = setup({ interactionModes: ['selectMarker'], markers })
122
+ act(() => eb.emit(DATA_CHANGE, {}))
123
+
124
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
125
+ items: [{ id: 'm1', label: MARKER_LABEL }], multiselectable: false
126
+ })
127
+ container.remove()
128
+ })
129
+ })
130
+
131
+ // ─── useMapItemList — selectMarker mode ───────────────────────────────────
132
+
133
+ describe('useMapItemList — selectMarker mode', () => {
134
+ afterEach(() => { document.body.innerHTML = '' })
135
+
136
+ it('emits visible markers as items on moveend', () => {
137
+ const { el, container } = makeMarkerEl({ inViewport: true })
138
+ const markers = makeMarkers([{ id: 'm1', label: MARKER_LABEL, symbol: 'pin', isVisible: true }])
139
+ markers.markerRefs.set('m1', el)
140
+
141
+ const { eb } = setup({ interactionModes: ['selectMarker'], markers })
142
+ act(() => eb.emit(MOVE_END, {}))
143
+
144
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
145
+ items: [{ id: 'm1', label: MARKER_LABEL }], multiselectable: false
146
+ })
147
+ container.remove()
148
+ })
149
+
150
+ it('excludes markers whose element has no .im-c-viewport__markers ancestor', () => {
151
+ const el = document.createElement('div')
152
+ document.body.appendChild(el)
153
+ const markers = makeMarkers([{ id: 'm1', label: 'Orphan', symbol: 'pin', isVisible: true }])
154
+ markers.markerRefs.set('m1', el)
155
+
156
+ const { eb } = setup({ interactionModes: ['selectMarker'], markers })
157
+ act(() => eb.emit(MOVE_END, {}))
158
+
159
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
160
+ el.remove()
161
+ })
162
+
163
+ it('excludes markers outside the viewport', () => {
164
+ const { el, container } = makeMarkerEl({ inViewport: false })
165
+ const markers = makeMarkers([{ id: 'm1', label: 'Offscreen', symbol: 'pin', isVisible: true }])
166
+ markers.markerRefs.set('m1', el)
167
+
168
+ const { eb } = setup({ interactionModes: ['selectMarker'], markers })
169
+ act(() => eb.emit(MOVE_END, {}))
170
+
171
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
172
+ container.remove()
173
+ })
174
+
175
+ it('excludes standalone label markers', () => {
176
+ const { el, container } = makeMarkerEl({ inViewport: true })
177
+ const markers = makeMarkers([{ id: 'm1', label: 'Label', symbol: null, isVisible: true }])
178
+ markers.markerRefs.set('m1', el)
179
+
180
+ const { eb } = setup({ interactionModes: ['selectMarker'], markers })
181
+ act(() => eb.emit(MOVE_END, {}))
182
+
183
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
184
+ container.remove()
185
+ })
186
+
187
+ it('excludes markers without a label', () => {
188
+ const { el, container } = makeMarkerEl({ inViewport: true })
189
+ const markers = makeMarkers([{ id: 'm1', symbol: 'pin', isVisible: true }])
190
+ markers.markerRefs.set('m1', el)
191
+
192
+ const { eb } = setup({ interactionModes: ['selectMarker'], markers })
193
+ act(() => eb.emit(MOVE_END, {}))
194
+
195
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
196
+ container.remove()
197
+ })
198
+ })
199
+
200
+ // ─── useMapItemList — selectFeature mode: label resolution ───────────────
201
+
202
+ describe('useMapItemList — selectFeature mode: label resolution', () => {
203
+ const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
204
+
205
+ it('emits layer features as items on moveend', () => {
206
+ const features = [
207
+ { layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'High Street' } }
208
+ ]
209
+ const { eb, mp } = setup({
210
+ interactionModes: ['selectFeature'],
211
+ layers,
212
+ mapProvider: makeMapProvider(features)
213
+ })
214
+
215
+ act(() => eb.emit(MOVE_END, {}))
216
+
217
+ expect(mp.getVisibleFeatures).toHaveBeenCalledWith(['roads'])
218
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
219
+ items: [{ id: '1', label: 'High Street' }], multiselectable: false
220
+ })
221
+ })
222
+
223
+ it('falls back to idProperty value when labelProperty is absent on feature', () => {
224
+ const features = [{ layer: { id: 'roads' }, properties: { road_id: '2' } }]
225
+ const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
226
+ act(() => eb.emit(MOVE_END, {}))
227
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
228
+ items: [{ id: '2', label: '2' }], multiselectable: false
229
+ })
230
+ })
231
+
232
+ it('excludes features from layers with no labelProperty configured', () => {
233
+ const features = [{ layer: { id: 'roads' }, properties: { road_id: '3' } }]
234
+ const { eb } = setup({
235
+ interactionModes: ['selectFeature'],
236
+ layers: [{ layerId: 'roads', idProperty: 'road_id' }],
237
+ mapProvider: makeMapProvider(features)
238
+ })
239
+ act(() => eb.emit(MOVE_END, {}))
240
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
241
+ items: [], multiselectable: false
242
+ })
243
+ })
244
+
245
+ it('uses feature.id when idProperty is not present in feature properties', () => {
246
+ const features = [{ layer: { id: 'roads' }, id: 42, properties: { road_name: 'Oak Ave' } }]
247
+ const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
248
+ act(() => eb.emit(MOVE_END, {}))
249
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
250
+ items: [{ id: '42', label: 'Oak Ave' }], multiselectable: false
251
+ })
252
+ })
253
+
254
+ it('skips feature when both idProperty and feature.id are absent', () => {
255
+ const features = [{ layer: { id: 'roads' }, properties: { road_name: 'Lost Lane' } }]
256
+ const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
257
+ act(() => eb.emit(MOVE_END, {}))
258
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
259
+ })
260
+
261
+ it('falls back to stringId label when feature has no properties object', () => {
262
+ // Line 45: feature.properties is undefined → ?. short-circuits → ?? stringId used
263
+ const features = [{ layer: { id: 'roads' }, id: 99 }]
264
+ const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
265
+ act(() => eb.emit(MOVE_END, {}))
266
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
267
+ items: [{ id: '99', label: '99' }], multiselectable: false
268
+ })
269
+ })
270
+
271
+ it('deduplicates features with the same id', () => {
272
+ const features = [
273
+ { layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'High Street' } },
274
+ { layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'Duplicate' } }
275
+ ]
276
+ const { eb } = setup({
277
+ interactionModes: ['selectFeature'],
278
+ layers: [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }],
279
+ mapProvider: makeMapProvider(features)
280
+ })
281
+ act(() => eb.emit(MOVE_END, {}))
282
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
283
+ items: [{ id: '1', label: 'High Street' }], multiselectable: false
284
+ })
285
+ })
286
+ })
287
+
288
+ // ─── useMapItemList — selectFeature mode: guards ─────────────────────────
289
+
290
+ describe('useMapItemList — selectFeature mode: guards', () => {
291
+ const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
292
+
293
+ it('skips features with no matching layer config', () => {
294
+ const features = [{ layer: { id: 'unknown-layer' }, properties: { road_id: '4' } }]
295
+ const { eb } = setup({ interactionModes: ['selectFeature'], layers, mapProvider: makeMapProvider(features) })
296
+ act(() => eb.emit(MOVE_END, {}))
297
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
298
+ })
299
+
300
+ it('emits empty items when layers array is empty', () => {
301
+ const { eb } = setup({ interactionModes: ['selectFeature'], layers: [] })
302
+ act(() => eb.emit(MOVE_END, {}))
303
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
304
+ })
305
+
306
+ it('includes multiselectable: true in payload when multiSelect is enabled', () => {
307
+ const features = [{ layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'High St' } }]
308
+ const { eb } = setup({
309
+ interactionModes: ['selectFeature'],
310
+ layers,
311
+ mapProvider: makeMapProvider(features),
312
+ multiSelect: true
313
+ })
314
+ act(() => eb.emit(MOVE_END, {}))
315
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
316
+ items: [{ id: '1', label: 'High St' }], multiselectable: true
317
+ })
318
+ })
319
+ })
320
+
321
+ // ─── useMapItemList — combined modes ─────────────────────────────────────
322
+
323
+ describe('useMapItemList — combined modes', () => {
324
+ afterEach(() => { document.body.innerHTML = '' })
325
+
326
+ it('emits both markers and features when both modes are active', () => {
327
+ const { el, container } = makeMarkerEl({ inViewport: true })
328
+ const markers = makeMarkers([{ id: 'm1', label: 'A Marker', symbol: 'pin', isVisible: true }])
329
+ markers.markerRefs.set('m1', el)
330
+
331
+ const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
332
+ const features = [{ layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'Main Rd' } }]
333
+
334
+ const { eb } = setup({
335
+ interactionModes: ['selectMarker', 'selectFeature'],
336
+ markers,
337
+ layers,
338
+ mapProvider: makeMapProvider(features)
339
+ })
340
+
341
+ act(() => eb.emit(MOVE_END, {}))
342
+
343
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, {
344
+ items: [{ id: 'm1', label: 'A Marker' }, { id: '1', label: 'Main Rd' }], multiselectable: false
345
+ })
346
+ container.remove()
347
+ })
348
+
349
+ it('emits empty items when no interaction modes are active', () => {
350
+ const { eb } = setup({ interactionModes: [] })
351
+ act(() => eb.emit(MOVE_END, {}))
352
+ expect(eb.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [], multiselectable: false })
353
+ })
354
+ })
355
+
356
+ // ─── useMapItemList — map:setactivefeature listener ──────────────────────
357
+
358
+ describe('useMapItemList — map:setactivefeature listener', () => {
359
+ afterEach(() => { document.body.innerHTML = '' })
360
+
361
+ it('subscribes to map:setactivefeature on mount and unsubscribes on unmount', () => {
362
+ const { eb, unmount } = setup()
363
+ expect(eb.on).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function)) // NOSONAR
364
+ unmount()
365
+ expect(eb.off).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function)) // NOSONAR
366
+ })
367
+
368
+ it('dispatches SET_LISTBOX_ACTIVE null when id is null', () => {
369
+ const { eb, dp } = setup({ interactionModes: ['selectMarker'] })
370
+ act(() => eb.emit(SET_ACTIVE, { id: null })) // NOSONAR
371
+ expect(dp).toHaveBeenCalledWith({ type: 'SET_LISTBOX_ACTIVE', payload: null })
372
+ })
373
+
374
+ it('dispatches SET_LISTBOX_ACTIVE null when id matches a marker (ring handled by Markers.jsx)', () => {
375
+ const markers = makeMarkers([{ id: 'm1', label: 'Marker', symbol: 'pin', isVisible: true }])
376
+ const { eb, dp } = setup({ interactionModes: ['selectMarker'], markers })
377
+ act(() => eb.emit(SET_ACTIVE, { id: 'm1' })) // NOSONAR
378
+ expect(dp).toHaveBeenCalledWith({ type: 'SET_LISTBOX_ACTIVE', payload: null })
379
+ })
380
+
381
+ it('dispatches SET_LISTBOX_ACTIVE with feature payload when id matches a feature', () => {
382
+ const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
383
+ const features = [
384
+ { layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'High St' }, geometry: { type: 'Point' } }
385
+ ]
386
+ const { eb, dp } = setup({
387
+ interactionModes: ['selectFeature'],
388
+ layers,
389
+ mapProvider: makeMapProvider(features)
390
+ })
391
+ act(() => eb.emit(SET_ACTIVE, { id: '1' })) // NOSONAR
392
+ expect(dp).toHaveBeenCalledWith({
393
+ type: 'SET_LISTBOX_ACTIVE',
394
+ payload: {
395
+ featureId: '1',
396
+ layerId: 'roads',
397
+ idProperty: 'road_id',
398
+ geometry: { type: 'Point' }
399
+ }
400
+ })
401
+ })
402
+
403
+ it('preserves raw numeric featureId in SET_LISTBOX_ACTIVE payload (MapLibre filter type-strictness)', () => {
404
+ const layers = [{ layerId: 'hedges', idProperty: 'id' }]
405
+ const features = [
406
+ { layer: { id: 'hedges' }, properties: { id: 27665979 }, geometry: { type: 'LineString' } }
407
+ ]
408
+ const { eb, dp } = setup({
409
+ interactionModes: ['selectFeature'],
410
+ layers,
411
+ mapProvider: makeMapProvider(features)
412
+ })
413
+ act(() => eb.emit(SET_ACTIVE, { id: '27665979' }))
414
+ expect(dp).toHaveBeenCalledWith({
415
+ type: 'SET_LISTBOX_ACTIVE',
416
+ payload: {
417
+ featureId: 27665979,
418
+ layerId: 'hedges',
419
+ idProperty: 'id',
420
+ geometry: { type: 'LineString' }
421
+ }
422
+ })
423
+ })
424
+
425
+ it('does not dispatch when feature id is not found in visible features', () => {
426
+ const layers = [{ layerId: 'roads', idProperty: 'road_id' }]
427
+ const { eb, dp } = setup({
428
+ interactionModes: ['selectFeature'],
429
+ layers,
430
+ mapProvider: makeMapProvider([])
431
+ })
432
+ act(() => eb.emit(SET_ACTIVE, { id: 'missing' })) // NOSONAR
433
+ expect(dp).not.toHaveBeenCalled()
434
+ })
435
+
436
+ it('skips features from unknown layers when searching by id', () => {
437
+ // Line 69: getFeatureId returns null for unknown layer → rawId != null is false
438
+ const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
439
+ const features = [{ layer: { id: 'unknown' }, properties: { some_id: '1' }, geometry: { type: 'Point' } }]
440
+ const { eb, dp } = setup({
441
+ interactionModes: ['selectFeature'],
442
+ layers,
443
+ mapProvider: makeMapProvider(features)
444
+ })
445
+ act(() => eb.emit(SET_ACTIVE, { id: '1' }))
446
+ expect(dp).not.toHaveBeenCalled()
447
+ })
448
+
449
+ it('does not search features when interactionModes excludes selectFeature', () => {
450
+ // Line 114: interactionModes.includes('selectFeature') → false → block skipped
451
+ const features = [{ layer: { id: 'roads' }, properties: { road_id: '1' }, geometry: { type: 'Point' } }]
452
+ const { eb, dp } = setup({
453
+ interactionModes: ['selectMarker'],
454
+ layers: [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }],
455
+ mapProvider: makeMapProvider(features)
456
+ })
457
+ act(() => eb.emit(SET_ACTIVE, { id: '1' }))
458
+ expect(dp).not.toHaveBeenCalled()
459
+ })
460
+
461
+ it('does not search features when layers is empty', () => {
462
+ // Line 114: layers.length > 0 → false → block skipped
463
+ const { eb, dp } = setup({ interactionModes: ['selectFeature'], layers: [] })
464
+ act(() => eb.emit(SET_ACTIVE, { id: '1' }))
465
+ expect(dp).not.toHaveBeenCalled()
466
+ })
467
+ })
468
+
469
+ // ─── useMapItemList — confirm: lifecycle and guards ──────────────────────
470
+
471
+ describe('useMapItemList — confirm: lifecycle and guards', () => {
472
+ it('subscribes to map:confirmfeature on mount and unsubscribes on unmount', () => {
473
+ const { eb, unmount } = setup()
474
+ expect(eb.on).toHaveBeenCalledWith(CONFIRM, expect.any(Function))
475
+ unmount()
476
+ expect(eb.off).toHaveBeenCalledWith(CONFIRM, expect.any(Function))
477
+ })
478
+
479
+ it('does nothing when no item is active', () => {
480
+ const { eb, dp } = setup()
481
+ act(() => eb.emit(CONFIRM))
482
+ expect(dp).not.toHaveBeenCalled()
483
+ })
484
+
485
+ it('keeps the active item after confirm so repeated confirms dispatch again', () => {
486
+ const markers = makeMarkers([{ id: 'm1', label: 'Marker', symbol: 'pin', isVisible: true }])
487
+ const { eb, dp } = setup({ interactionModes: ['selectMarker'], markers })
488
+ act(() => eb.emit(SET_ACTIVE, { id: 'm1' }))
489
+ act(() => eb.emit(CONFIRM))
490
+ dp.mockClear()
491
+ act(() => eb.emit(CONFIRM))
492
+ expect(dp).toHaveBeenCalledWith({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: 'm1', multiSelect: false } })
493
+ })
494
+ })
495
+
496
+ // ─── useMapItemList — confirm: dispatches ────────────────────────────────
497
+
498
+ describe('useMapItemList — confirm: marker dispatches', () => {
499
+ afterEach(() => { document.body.innerHTML = '' })
500
+
501
+ it('dispatches TOGGLE_SELECTED_MARKERS after activating a marker', () => {
502
+ const markers = makeMarkers([{ id: 'm1', label: 'Marker', symbol: 'pin', isVisible: true }])
503
+ const { eb, dp } = setup({ interactionModes: ['selectMarker'], markers })
504
+ act(() => eb.emit(SET_ACTIVE, { id: 'm1' }))
505
+ act(() => eb.emit(CONFIRM))
506
+ expect(dp).toHaveBeenCalledWith({
507
+ type: 'TOGGLE_SELECTED_MARKERS',
508
+ payload: { markerId: 'm1', multiSelect: false }
509
+ })
510
+ })
511
+ })
512
+
513
+ describe('useMapItemList — confirm: feature dispatches', () => {
514
+ it('dispatches TOGGLE_SELECTED_FEATURES after activating a feature', () => {
515
+ const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
516
+ const features = [
517
+ { layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'High St' }, geometry: { type: 'Point' } }
518
+ ]
519
+ const { eb, dp } = setup({
520
+ interactionModes: ['selectFeature'],
521
+ layers,
522
+ mapProvider: makeMapProvider(features)
523
+ })
524
+ act(() => eb.emit(SET_ACTIVE, { id: '1' }))
525
+ act(() => eb.emit(CONFIRM))
526
+ expect(dp).toHaveBeenCalledWith({
527
+ type: 'TOGGLE_SELECTED_FEATURES',
528
+ payload: {
529
+ featureId: '1',
530
+ multiSelect: false,
531
+ replaceAll: true,
532
+ layerId: 'roads',
533
+ idProperty: 'road_id',
534
+ properties: { road_id: '1', road_name: 'High St' },
535
+ geometry: { type: 'Point' }
536
+ }
537
+ })
538
+ })
539
+
540
+ it('dispatches with multiSelect: true and replaceAll: false when plugin multiSelect is enabled', () => {
541
+ const layers = [{ layerId: 'roads', idProperty: 'road_id', labelProperty: 'road_name' }]
542
+ const features = [
543
+ { layer: { id: 'roads' }, properties: { road_id: '1', road_name: 'High St' }, geometry: { type: 'Point' } }
544
+ ]
545
+ const { eb, dp } = setup({
546
+ interactionModes: ['selectFeature'],
547
+ layers,
548
+ mapProvider: makeMapProvider(features),
549
+ multiSelect: true
550
+ })
551
+ act(() => eb.emit(SET_ACTIVE, { id: '1' }))
552
+ act(() => eb.emit(CONFIRM))
553
+ expect(dp).toHaveBeenCalledWith({
554
+ type: 'TOGGLE_SELECTED_FEATURES',
555
+ payload: expect.objectContaining({ multiSelect: true, replaceAll: false })
556
+ })
557
+ })
558
+
559
+ it('preserves raw numeric featureId in TOGGLE_SELECTED_FEATURES payload', () => {
560
+ const layers = [{ layerId: 'hedges', idProperty: 'id' }]
561
+ const features = [
562
+ { layer: { id: 'hedges' }, properties: { id: 27665979 }, geometry: { type: 'LineString' } }
563
+ ]
564
+ const { eb, dp } = setup({
565
+ interactionModes: ['selectFeature'],
566
+ layers,
567
+ mapProvider: makeMapProvider(features)
568
+ })
569
+ act(() => eb.emit(SET_ACTIVE, { id: '27665979' }))
570
+ act(() => eb.emit(CONFIRM))
571
+ expect(dp).toHaveBeenCalledWith({
572
+ type: 'TOGGLE_SELECTED_FEATURES',
573
+ payload: {
574
+ featureId: 27665979,
575
+ multiSelect: false,
576
+ replaceAll: true,
577
+ layerId: 'hedges',
578
+ idProperty: 'id',
579
+ properties: { id: 27665979 },
580
+ geometry: { type: 'LineString' }
581
+ }
582
+ })
583
+ })
584
+ })
@@ -1,6 +1,4 @@
1
1
  // /plugins/interact/index.js
2
- import './interact.scss'
3
-
4
2
  export default function createPlugin (options = {}) {
5
3
  return {
6
4
  ...options,
@@ -1,7 +1,5 @@
1
1
  import createPlugin from './index.js'
2
2
 
3
- jest.mock('./interact.scss', () => ({}))
4
-
5
3
  describe('createPlugin', () => {
6
4
  it('returns plugin with fixed id and load function', () => {
7
5
  const plugin = createPlugin({ foo: 'bar' })
@@ -10,6 +10,8 @@ import { unselectFeature } from './api/unselectFeature.js'
10
10
  import { selectMarker } from './api/selectMarker.js'
11
11
  import { unselectMarker } from './api/unselectMarker.js'
12
12
 
13
+ const SELECT_FEATURES_GROUP = 'Select features'
14
+
13
15
  export const manifest = {
14
16
  InitComponent: InteractInit,
15
17
 
@@ -72,12 +74,41 @@ export const manifest = {
72
74
  }
73
75
  }],
74
76
 
75
- keyboardShortcuts: [{
76
- id: 'selectOrMark',
77
- group: 'Select',
78
- title: 'Select feature',
79
- command: '<kbd>Enter</kbd></dd>'
80
- }],
77
+ keyboardShortcuts: [
78
+ {
79
+ id: 'clickAtTarget',
80
+ title: 'Click at target',
81
+ command: '<kbd>Enter</kbd>'
82
+ },
83
+ {
84
+ id: 'selectFeatures',
85
+ group: SELECT_FEATURES_GROUP,
86
+ context: 'listbox',
87
+ title: 'Select features',
88
+ command: '<kbd>Tab</kbd>'
89
+ },
90
+ {
91
+ id: 'navigateFeatures',
92
+ group: SELECT_FEATURES_GROUP,
93
+ context: 'listbox',
94
+ title: 'Navigate features',
95
+ command: '<kbd>↑</kbd> or <kbd>↓</kbd>'
96
+ },
97
+ {
98
+ id: 'selectFeature',
99
+ group: SELECT_FEATURES_GROUP,
100
+ context: 'listbox',
101
+ title: 'Select a feature',
102
+ command: '<kbd>Enter</kbd> or <kbd>Space</kbd>'
103
+ },
104
+ {
105
+ id: 'dismissHintOrReturn',
106
+ group: SELECT_FEATURES_GROUP,
107
+ context: 'listbox',
108
+ title: 'Dismiss hint / return to map',
109
+ command: '<kbd>Escape</kbd>'
110
+ }
111
+ ],
81
112
 
82
113
  icons: [{
83
114
  id: 'select',