@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,635 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useFeatureFocus } from './useFeatureFocus.js'
3
+
4
+ const ITEMS = [
5
+ { id: 'a', label: 'Feature A' },
6
+ { id: 'b', label: 'Feature B' },
7
+ { id: 'c', label: 'Feature C' }
8
+ ]
9
+
10
+ const SET_ACTIVE = 'map:setactivefeature' // NOSONAR
11
+ const SELECT = 'map:selectfeature'
12
+ const SELECTION_CHANGE = 'interact:selectionchange'
13
+
14
+ const makeEventBus = () => {
15
+ const listeners = {}
16
+ return {
17
+ on: jest.fn((e, fn) => { listeners[e] = fn }),
18
+ off: jest.fn(),
19
+ emit: jest.fn(),
20
+ trigger: (e, payload) => listeners[e]?.(payload)
21
+ }
22
+ }
23
+
24
+ const makeRefs = ({ viewportFocus } = {}) => {
25
+ const viewportEl = document.createElement('div')
26
+ viewportEl.focus = viewportFocus ?? jest.fn()
27
+ return {
28
+ viewportRef: { current: viewportEl },
29
+ featuresRef: { current: document.createElement('ul') },
30
+ hints: { subscribe: jest.fn(() => jest.fn()), dismiss: jest.fn() }
31
+ }
32
+ }
33
+
34
+ const fireKey = (el, key) => {
35
+ let event
36
+ act(() => {
37
+ event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
38
+ el.dispatchEvent(event)
39
+ })
40
+ return event
41
+ }
42
+
43
+ // ─── useFeatureFocus — initial state ─────────────────────────────────────────
44
+
45
+ describe('useFeatureFocus — initial state', () => {
46
+ it('activeFeatureId starts as null', () => {
47
+ const { result } = renderHook(() => useFeatureFocus(makeRefs()))
48
+ expect(result.current.activeFeatureId).toBeNull()
49
+ })
50
+
51
+ it('exposes onFocus function', () => {
52
+ const { result } = renderHook(() => useFeatureFocus(makeRefs()))
53
+ expect(typeof result.current.onFocus).toBe('function')
54
+ })
55
+
56
+ it('exposes onBlur function', () => {
57
+ const { result } = renderHook(() => useFeatureFocus(makeRefs()))
58
+ expect(typeof result.current.onBlur).toBe('function')
59
+ })
60
+ })
61
+
62
+ // ─── useFeatureFocus — onFocus ────────────────────────────────────────────────
63
+
64
+ describe('useFeatureFocus — onFocus', () => {
65
+ it('sets activeFeatureId to first item on first focus (no previous, no selected)', () => {
66
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
67
+ act(() => result.current.onFocus())
68
+ expect(result.current.activeFeatureId).toBe('a')
69
+ })
70
+
71
+ it('does nothing when items is empty', () => {
72
+ const { result } = renderHook(() => useFeatureFocus(makeRefs()))
73
+ act(() => result.current.onFocus())
74
+ expect(result.current.activeFeatureId).toBeNull()
75
+ })
76
+
77
+ it('sets active to first selected item (highest priority)', () => {
78
+ const eb = makeEventBus()
79
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
80
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'b', layerId: 'roads' }] }))
81
+ act(() => result.current.onFocus())
82
+ expect(result.current.activeFeatureId).toBe('b')
83
+ })
84
+
85
+ it('selected item takes priority over last active position', () => {
86
+ const eb = makeEventBus()
87
+ const refs = makeRefs()
88
+ const el = refs.featuresRef.current
89
+ document.body.appendChild(el)
90
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
91
+ act(() => result.current.onFocus())
92
+ fireKey(el, 'ArrowDown') // last active = 'b'
93
+ act(() => result.current.onBlur())
94
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'c', layerId: 'roads' }] }))
95
+ act(() => result.current.onFocus())
96
+ expect(result.current.activeFeatureId).toBe('c') // selected beats last-active
97
+ unmount(); el.remove()
98
+ })
99
+
100
+ it('restores last active position when nothing is selected', () => {
101
+ const refs = makeRefs()
102
+ const el = refs.featuresRef.current
103
+ document.body.appendChild(el)
104
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
105
+ act(() => result.current.onFocus())
106
+ fireKey(el, 'ArrowDown') // last active = 'b'
107
+ act(() => result.current.onBlur())
108
+ act(() => result.current.onFocus())
109
+ expect(result.current.activeFeatureId).toBe('b')
110
+ unmount(); el.remove()
111
+ })
112
+
113
+ it('falls back to first item when previous active is no longer in the list', () => {
114
+ const eb = makeEventBus()
115
+ const refs = makeRefs()
116
+ const el = refs.featuresRef.current
117
+ document.body.appendChild(el)
118
+ const { result, rerender, unmount } = renderHook(
119
+ ({ items }) => useFeatureFocus({ ...refs, items, eventBus: eb }),
120
+ { initialProps: { items: ITEMS } }
121
+ )
122
+ act(() => result.current.onFocus())
123
+ fireKey(el, 'ArrowDown') // active = 'b'
124
+ act(() => result.current.onBlur())
125
+ const NEW_ITEMS = [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }]
126
+ rerender({ items: NEW_ITEMS })
127
+ act(() => result.current.onFocus())
128
+ expect(result.current.activeFeatureId).toBe('x')
129
+ unmount(); el.remove()
130
+ })
131
+ })
132
+
133
+ // ─── useFeatureFocus — onBlur ─────────────────────────────────────────────────
134
+
135
+ describe('useFeatureFocus — onBlur', () => {
136
+ it('clears activeFeatureId when focus leaves the listbox', () => {
137
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
138
+ act(() => result.current.onFocus())
139
+ expect(result.current.activeFeatureId).toBe('a')
140
+ act(() => result.current.onBlur())
141
+ expect(result.current.activeFeatureId).toBeNull()
142
+ })
143
+
144
+ it('emits map:setactivefeature with null on blur', () => {
145
+ const eb = makeEventBus()
146
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
147
+ act(() => result.current.onFocus())
148
+ eb.emit.mockClear()
149
+ act(() => result.current.onBlur())
150
+ expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: null })
151
+ })
152
+
153
+ it('does not throw when eventBus is not provided', () => {
154
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
155
+ expect(() => act(() => result.current.onBlur())).not.toThrow()
156
+ })
157
+ })
158
+
159
+ // ─── useFeatureFocus — keydown listener lifecycle ────────────────────────────
160
+
161
+ describe('useFeatureFocus — keydown listener lifecycle', () => {
162
+ it('does not attach listener when featuresRef.current is null', () => {
163
+ const refs = makeRefs()
164
+ refs.featuresRef = { current: null }
165
+ const spy = jest.spyOn(document.body, 'addEventListener')
166
+ renderHook(() => useFeatureFocus(refs))
167
+ expect(spy).not.toHaveBeenCalledWith('keydown', expect.any(Function))
168
+ spy.mockRestore()
169
+ })
170
+
171
+ it('attaches and removes keydown listener on the features element', () => {
172
+ const refs = makeRefs()
173
+ const el = refs.featuresRef.current
174
+ const addSpy = jest.spyOn(el, 'addEventListener')
175
+ const removeSpy = jest.spyOn(el, 'removeEventListener')
176
+ const { unmount } = renderHook(() => useFeatureFocus(refs))
177
+ expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
178
+ unmount()
179
+ expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
180
+ })
181
+ })
182
+
183
+ // ─── useFeatureFocus — unhandled keys ────────────────────────────────────────
184
+
185
+ describe('useFeatureFocus — unhandled keys', () => {
186
+ it('does nothing for keys that are not Escape or Arrow', () => {
187
+ const viewportFocus = jest.fn()
188
+ const refs = makeRefs({ viewportFocus })
189
+ const el = refs.featuresRef.current
190
+ document.body.appendChild(el)
191
+ const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
192
+ fireKey(el, 'Tab')
193
+ expect(result.current.activeFeatureId).toBeNull()
194
+ expect(viewportFocus).not.toHaveBeenCalled()
195
+ el.remove()
196
+ })
197
+ })
198
+
199
+ // ─── useFeatureFocus — Escape key ────────────────────────────────────────────
200
+
201
+ describe('useFeatureFocus — Escape key', () => {
202
+ it('dismisses hint on Escape when hint is visible', () => {
203
+ const refs = makeRefs()
204
+ refs.hints.subscribe.mockImplementation((fn) => {
205
+ fn({ html: 'test' })
206
+ return jest.fn()
207
+ })
208
+ const el = refs.featuresRef.current
209
+ document.body.appendChild(el)
210
+ const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
211
+ act(() => result.current.onFocus())
212
+ fireKey(el, 'Escape')
213
+ expect(refs.hints.dismiss).toHaveBeenCalled()
214
+ expect(refs.viewportRef.current.focus).not.toHaveBeenCalled()
215
+ el.remove()
216
+ })
217
+
218
+ it('focuses the viewport on Escape when no hint is visible', () => {
219
+ const viewportFocus = jest.fn()
220
+ const refs = makeRefs({ viewportFocus })
221
+ const el = refs.featuresRef.current
222
+ document.body.appendChild(el)
223
+ const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
224
+ act(() => result.current.onFocus())
225
+ fireKey(el, 'Escape')
226
+ expect(refs.hints.dismiss).not.toHaveBeenCalled()
227
+ expect(viewportFocus).toHaveBeenCalled()
228
+ el.remove()
229
+ })
230
+
231
+ it('does not throw when viewportRef.current is null', () => {
232
+ const refs = { viewportRef: { current: null }, featuresRef: { current: document.createElement('ul') }, hints: { subscribe: jest.fn(() => jest.fn()), dismiss: jest.fn() } }
233
+ const el = refs.featuresRef.current
234
+ document.body.appendChild(el)
235
+ renderHook(() => useFeatureFocus(refs))
236
+ expect(() => fireKey(el, 'Escape')).not.toThrow()
237
+ el.remove()
238
+ })
239
+
240
+ it('stops propagation on Escape so the viewport keyboard handler does not also handle it', () => {
241
+ const refs = makeRefs()
242
+ const el = refs.featuresRef.current
243
+ document.body.appendChild(el)
244
+ renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
245
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
246
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
247
+ act(() => el.dispatchEvent(event))
248
+ expect(stopSpy).toHaveBeenCalled()
249
+ el.remove()
250
+ })
251
+ })
252
+
253
+ // ─── useFeatureFocus — ArrowDown navigation ───────────────────────────────────
254
+
255
+ describe('useFeatureFocus — ArrowDown navigation', () => {
256
+ const setup = () => {
257
+ const refs = makeRefs()
258
+ const el = refs.featuresRef.current
259
+ document.body.appendChild(el)
260
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
261
+ return { result, el, unmount }
262
+ }
263
+
264
+ it('selects first item when no item is active', () => {
265
+ const { result, el, unmount } = setup()
266
+ fireKey(el, 'ArrowDown')
267
+ expect(result.current.activeFeatureId).toBe('a')
268
+ unmount(); el.remove()
269
+ })
270
+
271
+ it('advances to next item', () => {
272
+ const { result, el, unmount } = setup()
273
+ act(() => result.current.onFocus())
274
+ fireKey(el, 'ArrowDown')
275
+ expect(result.current.activeFeatureId).toBe('b')
276
+ unmount(); el.remove()
277
+ })
278
+
279
+ it('clamps at last item', () => {
280
+ const refs = makeRefs()
281
+ const el = refs.featuresRef.current
282
+ document.body.appendChild(el)
283
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
284
+ act(() => result.current.onFocus())
285
+ fireKey(el, 'ArrowDown')
286
+ fireKey(el, 'ArrowDown')
287
+ fireKey(el, 'ArrowDown')
288
+ expect(result.current.activeFeatureId).toBe('c')
289
+ unmount(); el.remove()
290
+ })
291
+
292
+ it('does nothing when items is empty', () => {
293
+ const refs = makeRefs()
294
+ const el = refs.featuresRef.current
295
+ document.body.appendChild(el)
296
+ const { result, unmount } = renderHook(() => useFeatureFocus(refs))
297
+ fireKey(el, 'ArrowDown')
298
+ expect(result.current.activeFeatureId).toBeNull()
299
+ unmount(); el.remove()
300
+ })
301
+
302
+ it('stops propagation on ArrowDown so the viewport keyboard handler does not pan the map', () => {
303
+ const { result, el, unmount } = setup()
304
+ act(() => result.current.onFocus())
305
+ const event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
306
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
307
+ act(() => el.dispatchEvent(event))
308
+ expect(stopSpy).toHaveBeenCalled()
309
+ unmount(); el.remove()
310
+ })
311
+ })
312
+
313
+ // ─── useFeatureFocus — ArrowUp navigation ────────────────────────────────────
314
+
315
+ describe('useFeatureFocus — ArrowUp navigation', () => {
316
+ it('selects last item when no item is active', () => {
317
+ const refs = makeRefs()
318
+ const el = refs.featuresRef.current
319
+ document.body.appendChild(el)
320
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
321
+ fireKey(el, 'ArrowUp')
322
+ expect(result.current.activeFeatureId).toBe('c')
323
+ unmount(); el.remove()
324
+ })
325
+
326
+ it('moves to previous item', () => {
327
+ const refs = makeRefs()
328
+ const el = refs.featuresRef.current
329
+ document.body.appendChild(el)
330
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
331
+ act(() => result.current.onFocus())
332
+ fireKey(el, 'ArrowDown')
333
+ fireKey(el, 'ArrowUp')
334
+ expect(result.current.activeFeatureId).toBe('a')
335
+ unmount(); el.remove()
336
+ })
337
+
338
+ it('clamps at first item', () => {
339
+ const refs = makeRefs()
340
+ const el = refs.featuresRef.current
341
+ document.body.appendChild(el)
342
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
343
+ act(() => result.current.onFocus())
344
+ fireKey(el, 'ArrowUp')
345
+ fireKey(el, 'ArrowUp')
346
+ expect(result.current.activeFeatureId).toBe('a')
347
+ unmount(); el.remove()
348
+ })
349
+ })
350
+
351
+ // ─── useFeatureFocus — eventBus integration ───────────────────────────────────
352
+
353
+ describe('useFeatureFocus — eventBus: map:setactivefeature listener', () => {
354
+ it('subscribes to map:setactivefeature on mount and unsubscribes on unmount', () => {
355
+ const eb = makeEventBus()
356
+ const { unmount } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
357
+ expect(eb.on).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function))
358
+ unmount()
359
+ expect(eb.off).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function))
360
+ })
361
+
362
+ it('updates activeFeatureId when map:setactivefeature is received', () => {
363
+ const eb = makeEventBus()
364
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
365
+ act(() => eb.trigger(SET_ACTIVE, { id: 'a' }))
366
+ expect(result.current.activeFeatureId).toBe('a')
367
+ })
368
+
369
+ it('clears activeFeatureId when map:setactivefeature is received with null', () => {
370
+ const eb = makeEventBus()
371
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
372
+ act(() => eb.trigger(SET_ACTIVE, { id: 'a' }))
373
+ act(() => eb.trigger(SET_ACTIVE, { id: null }))
374
+ expect(result.current.activeFeatureId).toBeNull()
375
+ })
376
+ })
377
+
378
+ const setupWithBus = () => {
379
+ const eb = makeEventBus()
380
+ const refs = makeRefs()
381
+ const el = refs.featuresRef.current
382
+ document.body.appendChild(el)
383
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
384
+ return { eb, el, result, unmount }
385
+ }
386
+
387
+ describe('useFeatureFocus — eventBus: map:setactivefeature emit', () => {
388
+ afterEach(() => { document.body.innerHTML = '' })
389
+
390
+ it('emits map:setactivefeature with first item id on onFocus', () => {
391
+ const { eb, result } = setupWithBus()
392
+ act(() => result.current.onFocus())
393
+ expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'a' })
394
+ })
395
+
396
+ it('emits map:setactivefeature with new id on ArrowDown', () => {
397
+ const { eb, result, el, unmount } = setupWithBus()
398
+ act(() => result.current.onFocus())
399
+ eb.emit.mockClear()
400
+ fireKey(el, 'ArrowDown')
401
+ expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'b' })
402
+ unmount()
403
+ })
404
+
405
+ it('does not emit when eventBus is not provided', () => {
406
+ const refs = makeRefs()
407
+ const el = refs.featuresRef.current
408
+ document.body.appendChild(el)
409
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
410
+ expect(() => {
411
+ act(() => result.current.onFocus())
412
+ fireKey(el, 'ArrowDown')
413
+ }).not.toThrow()
414
+ unmount()
415
+ })
416
+ })
417
+
418
+ // ─── useFeatureFocus — interact:selectionchange listener ─────────────────────
419
+
420
+ describe('useFeatureFocus — interact:selectionchange listener', () => {
421
+ it('subscribes to interact:selectionchange on mount and unsubscribes on unmount', () => {
422
+ const eb = makeEventBus()
423
+ const { unmount } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
424
+ expect(eb.on).toHaveBeenCalledWith(SELECTION_CHANGE, expect.any(Function))
425
+ unmount()
426
+ expect(eb.off).toHaveBeenCalledWith(SELECTION_CHANGE, expect.any(Function))
427
+ })
428
+
429
+ it('sets selectedIds from selected features', () => {
430
+ const eb = makeEventBus()
431
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
432
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 27665979, layerId: 'hedges' }] }))
433
+ expect(result.current.selectedIds).toEqual(['27665979'])
434
+ })
435
+
436
+ it('sets selectedIds from multiple selected features', () => {
437
+ const eb = makeEventBus()
438
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
439
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }, { featureId: 'f2', layerId: 'roads' }] }))
440
+ expect(result.current.selectedIds).toEqual(['f1', 'f2'])
441
+ })
442
+
443
+ it('sets selectedIds from selected markers', () => {
444
+ const eb = makeEventBus()
445
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
446
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['m1', 'm2'] }))
447
+ expect(result.current.selectedIds).toEqual(['m1', 'm2'])
448
+ })
449
+
450
+ it('sets selectedIds from both features and markers when both are selected', () => {
451
+ const eb = makeEventBus()
452
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
453
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }], selectedMarkers: ['m1'] }))
454
+ expect(result.current.selectedIds).toEqual(['f1', 'm1'])
455
+ })
456
+
457
+ it('clears selectedIds when selection becomes empty', () => {
458
+ const eb = makeEventBus()
459
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
460
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }] }))
461
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [], selectedMarkers: [] }))
462
+ expect(result.current.selectedIds).toEqual([])
463
+ })
464
+
465
+ it('does not move activeFeatureId when selectionchange fires (cursor is keyboard-only)', () => {
466
+ const eb = makeEventBus()
467
+ const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
468
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'a', layerId: 'roads' }] }))
469
+ expect(result.current.activeFeatureId).toBeNull()
470
+ })
471
+ })
472
+
473
+ // ─── useFeatureFocus — map interaction returns focus to viewport ──────────────
474
+
475
+ describe('useFeatureFocus — map pointerdown returns focus to viewport', () => {
476
+ const pointerDown = (target) => act(() => {
477
+ target.dispatchEvent(new Event('pointerdown', { bubbles: true, cancelable: true }))
478
+ })
479
+
480
+ it('focuses the viewport when pointer lands on the map while listbox is focused', () => {
481
+ const viewportFocus = jest.fn()
482
+ const refs = makeRefs({ viewportFocus })
483
+ document.body.appendChild(refs.viewportRef.current)
484
+ const mapCanvas = document.createElement('canvas')
485
+ refs.viewportRef.current.appendChild(mapCanvas)
486
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
487
+ act(() => result.current.onFocus())
488
+ viewportFocus.mockClear()
489
+ pointerDown(mapCanvas)
490
+ expect(viewportFocus).toHaveBeenCalled()
491
+ unmount()
492
+ })
493
+
494
+ it('does not move focus when pointer lands inside the listbox', () => {
495
+ const viewportFocus = jest.fn()
496
+ const refs = makeRefs({ viewportFocus })
497
+ document.body.appendChild(refs.viewportRef.current)
498
+ refs.viewportRef.current.appendChild(refs.featuresRef.current)
499
+ const option = document.createElement('li')
500
+ refs.featuresRef.current.appendChild(option)
501
+ const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
502
+ act(() => result.current.onFocus())
503
+ viewportFocus.mockClear()
504
+ pointerDown(option)
505
+ expect(viewportFocus).not.toHaveBeenCalled()
506
+ unmount()
507
+ })
508
+
509
+ it('does not move focus when pointer fires while listbox is not focused', () => {
510
+ const viewportFocus = jest.fn()
511
+ const refs = makeRefs({ viewportFocus })
512
+ document.body.appendChild(refs.viewportRef.current)
513
+ const mapCanvas = document.createElement('canvas')
514
+ refs.viewportRef.current.appendChild(mapCanvas)
515
+ const { unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
516
+ pointerDown(mapCanvas)
517
+ expect(viewportFocus).not.toHaveBeenCalled()
518
+ unmount()
519
+ })
520
+
521
+ afterEach(() => { document.body.innerHTML = '' })
522
+ })
523
+
524
+ // ─── useFeatureFocus — Enter and Space keys ───────────────────────────────────
525
+
526
+ describe('useFeatureFocus — Enter and Space keys', () => {
527
+ afterEach(() => { document.body.innerHTML = '' })
528
+
529
+ it('emits map:selectfeature on Enter', () => {
530
+ const { eb, result, el, unmount } = setupWithBus()
531
+ act(() => result.current.onFocus())
532
+ eb.emit.mockClear()
533
+ fireKey(el, 'Enter')
534
+ expect(eb.emit).toHaveBeenCalledWith(SELECT)
535
+ unmount()
536
+ })
537
+
538
+ it('emits map:selectfeature on Space', () => {
539
+ const { eb, result, el, unmount } = setupWithBus()
540
+ act(() => result.current.onFocus())
541
+ eb.emit.mockClear()
542
+ fireKey(el, ' ')
543
+ expect(eb.emit).toHaveBeenCalledWith(SELECT)
544
+ unmount()
545
+ })
546
+
547
+ it('stops propagation on Enter', () => {
548
+ const { el, result, unmount } = setupWithBus()
549
+ act(() => result.current.onFocus())
550
+ const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
551
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
552
+ act(() => el.dispatchEvent(event))
553
+ expect(stopSpy).toHaveBeenCalled()
554
+ unmount()
555
+ })
556
+
557
+ it('stops propagation on Space', () => {
558
+ const { el, result, unmount } = setupWithBus()
559
+ act(() => result.current.onFocus())
560
+ const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
561
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
562
+ act(() => el.dispatchEvent(event))
563
+ expect(stopSpy).toHaveBeenCalled()
564
+ unmount()
565
+ })
566
+ })
567
+
568
+ // ─── useFeatureFocus — items change while focused ─────────────────────────────
569
+
570
+ describe('useFeatureFocus — items change while focused', () => {
571
+ afterEach(() => { document.body.innerHTML = '' })
572
+
573
+ const setupRerender = (initialItems) => {
574
+ const eb = makeEventBus()
575
+ const refs = makeRefs()
576
+ document.body.appendChild(refs.featuresRef.current)
577
+ const { result, rerender, unmount } = renderHook(
578
+ ({ items }) => useFeatureFocus({ ...refs, items, eventBus: eb }),
579
+ { initialProps: { items: initialItems } }
580
+ )
581
+ return { eb, result, rerender, unmount }
582
+ }
583
+
584
+ it('keeps current active item when it is still in the updated list', () => {
585
+ const { result, rerender, unmount } = setupRerender(ITEMS)
586
+ act(() => result.current.onFocus()) // active = 'a'
587
+ act(() => { rerender({ items: [{ id: 'a', label: 'A updated' }, { id: 'd', label: 'D' }] }) })
588
+ expect(result.current.activeFeatureId).toBe('a')
589
+ unmount()
590
+ })
591
+
592
+ it('re-picks by priority when current active item leaves the list — first selected wins', () => {
593
+ const { eb, result, rerender, unmount } = setupRerender(ITEMS)
594
+ act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'c', layerId: 'l' }] }))
595
+ act(() => result.current.onFocus()) // active = 'a' (first, no prev)
596
+ act(() => { rerender({ items: [{ id: 'c', label: 'C' }, { id: 'd', label: 'D' }] }) }) // 'a' gone
597
+ expect(result.current.activeFeatureId).toBe('c') // first selected
598
+ unmount()
599
+ })
600
+
601
+ it('re-picks first item when active leaves and no selected item is in the new list', () => {
602
+ const { result, rerender, unmount } = setupRerender(ITEMS)
603
+ act(() => result.current.onFocus()) // active = 'a'
604
+ act(() => { rerender({ items: [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }] }) })
605
+ expect(result.current.activeFeatureId).toBe('x')
606
+ unmount()
607
+ })
608
+
609
+ it('does not change active item when the listbox is not focused', () => {
610
+ const { result, rerender, unmount } = setupRerender(ITEMS)
611
+ // never called onFocus — isFocusedRef stays false
612
+ act(() => { rerender({ items: [{ id: 'x', label: 'X' }] }) })
613
+ expect(result.current.activeFeatureId).toBeNull()
614
+ unmount()
615
+ })
616
+
617
+ it('emits map:setactivefeature with new id when re-pick occurs', () => {
618
+ const { eb, result, rerender, unmount } = setupRerender(ITEMS)
619
+ act(() => result.current.onFocus())
620
+ eb.emit.mockClear()
621
+ act(() => { rerender({ items: [{ id: 'x', label: 'X' }] }) }) // 'a' gone
622
+ expect(eb.emit).toHaveBeenCalledWith('map:setactivefeature', { id: 'x' })
623
+ unmount()
624
+ })
625
+
626
+ it('clears active and emits null when items list becomes empty while focused', () => {
627
+ const { eb, result, rerender, unmount } = setupRerender(ITEMS)
628
+ act(() => result.current.onFocus())
629
+ eb.emit.mockClear()
630
+ act(() => { rerender({ items: [] }) })
631
+ expect(result.current.activeFeatureId).toBeNull()
632
+ expect(eb.emit).toHaveBeenCalledWith('map:setactivefeature', { id: null })
633
+ unmount()
634
+ })
635
+ })
@@ -0,0 +1,30 @@
1
+ import { useState, useEffect } from 'react'
2
+
3
+ /**
4
+ * Subscribes to map:setfeatures and exposes the current listbox item list and multiselectable flag.
5
+ * The list is produced by useMapItemList in the interact plugin and pushed via the event bus
6
+ * whenever the visible set of markers or features changes.
7
+ *
8
+ * @param {object} eventBus
9
+ * @returns {{ items: Array<{ id: string, label: string }>, multiselectable: boolean }}
10
+ */
11
+ export function useFeatureItems (eventBus) {
12
+ const [items, setItems] = useState([])
13
+ const [multiselectable, setMultiselectable] = useState(false)
14
+
15
+ useEffect(() => {
16
+ if (!eventBus) {
17
+ return undefined
18
+ }
19
+ const handle = ({ items: next = [], multiselectable: nextMultiselectable = false }) => {
20
+ setItems(next)
21
+ setMultiselectable(nextMultiselectable)
22
+ }
23
+ eventBus.on('map:setfeatures', handle)
24
+ return () => {
25
+ eventBus.off('map:setfeatures', handle)
26
+ }
27
+ }, [eventBus])
28
+
29
+ return { items, multiselectable }
30
+ }