@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
@@ -1,257 +0,0 @@
1
- import { useState, useEffect, useRef } from 'react'
2
- import { EVENTS } from '../../config/events.js'
3
-
4
- const getNavigatedId = (id, key, items) => {
5
- if (!items.length) {
6
- return id
7
- }
8
- if (key === 'Home') {
9
- return items[0].id
10
- }
11
- if (key === 'End') {
12
- return items[items.length - 1].id
13
- }
14
- const idx = items.findIndex(item => item.id === id)
15
- if (key === 'ArrowDown') {
16
- return idx === -1 ? items[0].id : items[Math.min(idx + 1, items.length - 1)].id
17
- }
18
- return idx === -1 ? items[items.length - 1].id : items[Math.max(idx - 1, 0)].id
19
- }
20
-
21
- // ARIA listbox entry priority: first selected → last active (if still in list) → first item
22
- const resolveEntryId = (items, lastActiveId, selectedIds) => {
23
- const firstSelected = items.find(item => selectedIds.includes(item.id))
24
- if (firstSelected) {
25
- return firstSelected.id
26
- }
27
- if (lastActiveId && items.some(item => item.id === lastActiveId)) {
28
- return lastActiveId
29
- }
30
- return items[0].id
31
- }
32
-
33
- // Roving tabindex: moves real focus to the option matching id, found via data-id (avoids
34
- // CSS-escaping issues a querySelector would have with arbitrary ids). Flags
35
- // isInternalFocusMoveRef around the .focus() call so onFocus/onBlur can ignore this as an
36
- // internal move, not a real widget entry/exit — event.relatedTarget can't be used for that:
37
- // preact/compat (react is aliased to it in this app's build) doesn't populate it on synthetic
38
- // focus/blur events, so it's silently unreliable outside tests (real React + jsdom).
39
- const focusOption = (listboxEl, id, isInternalFocusMoveRef) => {
40
- const el = Array.from(listboxEl?.children ?? []).find(li => li.dataset.id === String(id))
41
- if (!el) {
42
- return
43
- }
44
- isInternalFocusMoveRef.current = true
45
- el.focus({ preventScroll: true })
46
- isInternalFocusMoveRef.current = false
47
- }
48
-
49
- /**
50
- * Keeps local selectedIds in sync with interact:selectionchange, and mirrors
51
- * MAP_SET_ACTIVE_FEATURE back into React state so the roving tabindex position stays current.
52
- */
53
- function useEventBusListeners ({ eventBus, lastActiveIdRef, setActiveFeatureId, setSelectedIds }) {
54
- useEffect(() => {
55
- if (!eventBus) {
56
- return undefined
57
- }
58
- const handleSetActive = ({ id }) => {
59
- if (id !== null) {
60
- lastActiveIdRef.current = id
61
- }
62
- setActiveFeatureId(id)
63
- }
64
- const handleSelectionChange = ({ selectedFeatures = [], selectedMarkers = [] }) => {
65
- setSelectedIds([...selectedFeatures.map(f => String(f.featureId)), ...selectedMarkers])
66
- }
67
- eventBus.on(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
68
- eventBus.on('interact:selectionchange', handleSelectionChange)
69
- return () => {
70
- eventBus.off(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
71
- eventBus.off('interact:selectionchange', handleSelectionChange)
72
- }
73
- }, [eventBus])
74
- }
75
-
76
- /**
77
- * Re-picks the active item (ARIA priority order) when it drops out of the item list while
78
- * focused — e.g. panned off screen — and moves real focus to it.
79
- */
80
- function useItemsRevalidation ({ items, eventBus, isFocusedRef, featuresRef, isInternalFocusMoveRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId }) {
81
- useEffect(() => {
82
- if (!isFocusedRef.current) {
83
- return
84
- }
85
- if (!items.length) {
86
- setActiveFeatureId(null)
87
- eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: null })
88
- return
89
- }
90
- if (items.some(item => item.id === activeFeatureIdRef.current)) {
91
- return
92
- }
93
- const nextId = resolveEntryId(items, lastActiveIdRef.current, selectedIdsRef.current)
94
- lastActiveIdRef.current = nextId
95
- setActiveFeatureId(nextId)
96
- eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: nextId })
97
- focusOption(featuresRef.current, nextId, isInternalFocusMoveRef)
98
- }, [items]) // NOSONAR — eventBus/selectedIds consumed via refs to avoid spurious re-runs on selection change
99
- }
100
-
101
- /**
102
- * Attaches a keydown listener to the listbox element for ARIA keyboard navigation:
103
- * - ArrowUp/ArrowDown — move the active item, moving real focus to it (roving tabindex)
104
- * - Home/End — jump the active item to the first/last option
105
- * - Enter/Space — confirm selection, emitting MAP_SELECT_FEATURE
106
- * - Escape — return focus to the map viewport
107
- */
108
- function useKeyboardNavigation ({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, isInternalFocusMoveRef, hints, currentHintRef }) {
109
- useEffect(() => {
110
- const listboxEl = featuresRef.current
111
- if (!listboxEl) {
112
- return undefined
113
- }
114
- const handleKeyDown = (event) => {
115
- if (event.key === 'Escape') {
116
- event.preventDefault()
117
- event.stopPropagation()
118
- if (currentHintRef.current) {
119
- hints.dismiss()
120
- } else {
121
- viewportRef.current?.focus()
122
- }
123
- } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
124
- event.preventDefault()
125
- event.stopPropagation()
126
- const newId = getNavigatedId(activeFeatureIdRef.current, event.key, items)
127
- lastActiveIdRef.current = newId
128
- setActiveFeatureId(newId)
129
- eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: newId })
130
- focusOption(listboxEl, newId, isInternalFocusMoveRef)
131
- } else if (event.key === 'Enter' || event.key === ' ') {
132
- event.preventDefault()
133
- event.stopPropagation()
134
- eventBus?.emit(EVENTS.MAP_SELECT_FEATURE)
135
- } else {
136
- // No action
137
- }
138
- }
139
- listboxEl.addEventListener('keydown', handleKeyDown)
140
- return () => { listboxEl.removeEventListener('keydown', handleKeyDown) }
141
- }, [viewportRef, featuresRef, items, eventBus])
142
- }
143
-
144
- /**
145
- * Returns focus to the viewport when the user interacts with the map via pointer while the
146
- * listbox is focused — clears its focus ring without dropping focus to nowhere.
147
- */
148
- function useMapInteractionBlur ({ viewportRef, featuresRef, isFocusedRef }) {
149
- useEffect(() => {
150
- const el = viewportRef.current
151
- if (!el) {
152
- return undefined
153
- }
154
- const handlePointerDown = (event) => {
155
- if (isFocusedRef.current && !featuresRef.current?.contains(event.target)) {
156
- viewportRef.current?.focus()
157
- }
158
- }
159
- el.addEventListener('pointerdown', handlePointerDown)
160
- return () => { el.removeEventListener('pointerdown', handlePointerDown) }
161
- }, [viewportRef, featuresRef])
162
- }
163
-
164
- /**
165
- * Manages roving-tabindex focus state for the keyboard-accessible feature list. On focus, sets
166
- * activeFeatureId via ARIA priority order (see resolveEntryId); on blur, clears it. Revalidates
167
- * when the item list changes (e.g. after a map pan) so it never points to a stale item.
168
- *
169
- * @param {{ viewportRef: React.RefObject, featuresRef: React.RefObject, items: Array, eventBus: object }} params
170
- * @returns {{ activeFeatureId: string|null, tabbableId: string|null, selectedIds: string[], onFocus: Function, onBlur: Function, selectItem: Function }}
171
- */
172
- export function useFeatureFocus ({ viewportRef, featuresRef, items = [], eventBus, hints }) {
173
- const [activeFeatureId, setActiveFeatureId] = useState(null)
174
- const [selectedIds, setSelectedIds] = useState([])
175
-
176
- const isFocusedRef = useRef(false)
177
- const lastActiveIdRef = useRef(null) // preserved across blur; restores position on re-focus
178
- const activeFeatureIdRef = useRef(null) // always-current for keydown closure
179
- const selectedIdsRef = useRef([]) // always-current for items-change effect
180
- const currentHintRef = useRef(null)
181
- const isInternalFocusMoveRef = useRef(false) // true only while focusOption()'s own .focus() call is in flight
182
-
183
- useEffect(() => {
184
- return hints.subscribe((hint) => {
185
- currentHintRef.current = hint
186
- })
187
- }, [hints])
188
-
189
- // Always-current mirrors for values read from event-listener closures (not React re-renders) —
190
- // assigned directly during render, same convention as useVisibleGeometry.js's latestRef.
191
- activeFeatureIdRef.current = activeFeatureId
192
- selectedIdsRef.current = selectedIds
193
-
194
- useEventBusListeners({ eventBus, lastActiveIdRef, setActiveFeatureId, setSelectedIds })
195
- useItemsRevalidation({ items, eventBus, isFocusedRef, featuresRef, isInternalFocusMoveRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId })
196
- useKeyboardNavigation({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, isInternalFocusMoveRef, hints, currentHintRef })
197
- useMapInteractionBlur({ viewportRef, featuresRef, isFocusedRef })
198
-
199
- // Resting roving-tabindex position — where Tab lands before the list has ever had real focus.
200
- // activeFeatureId takes priority once the list is actually focused. Deliberately never
201
- // selection-driven (unlike onFocus's own resolution below) — sticks to the last established
202
- // keyboard position, or the first item if there isn't one yet. "Prefer the selected item" is
203
- // an onFocus-only concern (a real Tab-in); if it applied here too, any selection change made
204
- // elsewhere (e.g. clicking a marker on the map, which never touches lastActiveIdRef) would
205
- // keep relocating tabIndex to follow it, even though nothing about keyboard state changed.
206
- let tabbableId = null
207
- if (items.length) {
208
- const hasEstablishedPosition = lastActiveIdRef.current && items.some(item => item.id === lastActiveIdRef.current)
209
- tabbableId = hasEstablishedPosition ? lastActiveIdRef.current : items[0].id
210
- }
211
-
212
- const onFocus = () => {
213
- isFocusedRef.current = true
214
- // Ignore focus moving between sibling options (roving tabindex) — the keydown/selectItem
215
- // path that moved it already resolved and emitted the correct id; re-resolving here would
216
- // clobber that emit.
217
- if (isInternalFocusMoveRef.current) {
218
- return
219
- }
220
- if (!items.length) {
221
- return
222
- }
223
- // A real Tab-in always prefers a selected item over the remembered position (native listbox
224
- // behaviour) — can't reuse tabbableId here, which deliberately does the opposite (sticks to
225
- // the remembered position, ignoring selection) once one is established.
226
- const id = resolveEntryId(items, lastActiveIdRef.current, selectedIds)
227
- lastActiveIdRef.current = id
228
- setActiveFeatureId(id)
229
- eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id })
230
- // Real focus may have landed on a different option than this resolves to — tabbableId (which
231
- // decided where Tab lands) can legitimately disagree with resolveEntryId's own priority (e.g.
232
- // Tab lands on the structural first item, but a different item is selected and takes
233
- // priority here). Move real focus to match so it never disagrees with activeFeatureId.
234
- focusOption(featuresRef.current, id, isInternalFocusMoveRef)
235
- }
236
-
237
- const onBlur = () => {
238
- // Ignore focus moving between sibling options (roving tabindex) — not a real exit.
239
- if (isInternalFocusMoveRef.current) {
240
- return
241
- }
242
- isFocusedRef.current = false
243
- setActiveFeatureId(null)
244
- eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: null })
245
- }
246
-
247
- // Mirrors keyboard Enter/Space: make the clicked item active, focus it, then confirm selection.
248
- const selectItem = (id) => {
249
- lastActiveIdRef.current = id
250
- setActiveFeatureId(id)
251
- eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id })
252
- focusOption(featuresRef.current, id, isInternalFocusMoveRef)
253
- eventBus?.emit(EVENTS.MAP_SELECT_FEATURE)
254
- }
255
-
256
- return { activeFeatureId, tabbableId, selectedIds, onFocus, onBlur, selectItem }
257
- }
@@ -1,39 +0,0 @@
1
- import { useState, useEffect } from 'react'
2
-
3
- const SET_FEATURES = 'map:setfeatures'
4
- const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed'
5
-
6
- /**
7
- * Subscribes to map:setfeatures for the listbox item list, and map:setfeaturessuppressed
8
- * (any plugin can emit this) to report items as empty while suppressed regardless of what's
9
- * actually visible — which is what drives <Features>'s own tabIndex/aria-hidden.
10
- *
11
- * @param {object} eventBus
12
- * @returns {{ items: Array<{ id: string, label: string, x?: number, y?: number }>, multiselectable: boolean }}
13
- */
14
- export function useFeatureItems (eventBus) {
15
- const [items, setItems] = useState([])
16
- const [multiselectable, setMultiselectable] = useState(false)
17
- const [suppressed, setSuppressed] = useState(false)
18
-
19
- useEffect(() => {
20
- if (!eventBus) {
21
- return undefined
22
- }
23
- const handleSetFeatures = ({ items: next = [], multiselectable: nextMultiselectable = false }) => {
24
- setItems(next)
25
- setMultiselectable(nextMultiselectable)
26
- }
27
- const handleSuppressed = ({ suppressed: next = false } = {}) => {
28
- setSuppressed(next)
29
- }
30
- eventBus.on(SET_FEATURES, handleSetFeatures)
31
- eventBus.on(SET_FEATURES_SUPPRESSED, handleSuppressed)
32
- return () => {
33
- eventBus.off(SET_FEATURES, handleSetFeatures)
34
- eventBus.off(SET_FEATURES_SUPPRESSED, handleSuppressed)
35
- }
36
- }, [eventBus])
37
-
38
- return { items: suppressed ? [] : items, multiselectable }
39
- }
@@ -1,155 +0,0 @@
1
- import { renderHook, act } from '@testing-library/react'
2
- import { useFeatureItems } from './useFeatureItems.js'
3
-
4
- const SET_FEATURES = 'map:setfeatures' // NOSONAR
5
- const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed' // NOSONAR
6
-
7
- const makeEventBus = () => {
8
- const listeners = {}
9
- return {
10
- on: jest.fn((e, fn) => { listeners[e] = fn }),
11
- off: jest.fn(),
12
- emit: (e, payload) => listeners[e]?.(payload)
13
- }
14
- }
15
-
16
- // ─── useFeatureItems — initial state ─────────────────────────────────────────
17
-
18
- describe('useFeatureItems — initial state', () => {
19
- it('returns empty items and multiselectable false before any event', () => {
20
- const { result } = renderHook(() => useFeatureItems(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(() => useFeatureItems(undefined))
27
- expect(result.current.items).toEqual([])
28
- expect(result.current.multiselectable).toBe(false)
29
- })
30
- })
31
-
32
- // ─── useFeatureItems — event subscription ────────────────────────────────────
33
-
34
- describe('useFeatureItems — event subscription', () => {
35
- it('subscribes to map:setfeatures on mount', () => {
36
- const eb = makeEventBus()
37
- renderHook(() => useFeatureItems(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(() => useFeatureItems(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(() => useFeatureItems(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(() => useFeatureItems(undefined))
58
- expect(spy).not.toHaveBeenCalled()
59
- spy.mockRestore()
60
- })
61
- })
62
-
63
- // ─── useFeatureItems — items updates ─────────────────────────────────────────
64
-
65
- describe('useFeatureItems — items updates', () => {
66
- it('updates items when map:setfeatures is emitted', () => {
67
- const eb = makeEventBus()
68
- const { result } = renderHook(() => useFeatureItems(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(() => useFeatureItems(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(() => useFeatureItems(eb))
85
- act(() => eb.emit(SET_FEATURES, {}))
86
- expect(result.current.items).toEqual([])
87
- })
88
- })
89
-
90
- // ─── useFeatureItems — multiselectable updates ───────────────────────────────
91
-
92
- describe('useFeatureItems — multiselectable updates', () => {
93
- it('sets multiselectable true when emitted with multiselectable: true', () => {
94
- const eb = makeEventBus()
95
- const { result } = renderHook(() => useFeatureItems(eb))
96
- act(() => eb.emit(SET_FEATURES, { items: [], multiselectable: true }))
97
- expect(result.current.multiselectable).toBe(true)
98
- })
99
-
100
- it('defaults multiselectable to false when not present in payload', () => {
101
- const eb = makeEventBus()
102
- const { result } = renderHook(() => useFeatureItems(eb))
103
- act(() => eb.emit(SET_FEATURES, { items: [] }))
104
- expect(result.current.multiselectable).toBe(false)
105
- })
106
- })
107
-
108
- // ─── useFeatureItems — suppression ───────────────────────────────────────────
109
-
110
- describe('useFeatureItems — suppression', () => {
111
- const items = [{ id: 'a', label: 'Feature A' }]
112
-
113
- it('reports empty items while suppressed, regardless of what was last set', () => {
114
- const eb = makeEventBus()
115
- const { result } = renderHook(() => useFeatureItems(eb))
116
- act(() => eb.emit(SET_FEATURES, { items }))
117
- act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
118
- expect(result.current.items).toEqual([])
119
- })
120
-
121
- it('restores the last known items once suppression is lifted', () => {
122
- const eb = makeEventBus()
123
- const { result } = renderHook(() => useFeatureItems(eb))
124
- act(() => eb.emit(SET_FEATURES, { items }))
125
- act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
126
- act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
127
- expect(result.current.items).toEqual(items)
128
- })
129
-
130
- it('defaults suppressed to false when the payload omits it', () => {
131
- const eb = makeEventBus()
132
- const { result } = renderHook(() => useFeatureItems(eb))
133
- act(() => eb.emit(SET_FEATURES, { items }))
134
- act(() => eb.emit(SET_FEATURES_SUPPRESSED, {}))
135
- expect(result.current.items).toEqual(items)
136
- })
137
-
138
- it('defaults suppressed to false when the event carries no payload at all', () => {
139
- const eb = makeEventBus()
140
- const { result } = renderHook(() => useFeatureItems(eb))
141
- act(() => eb.emit(SET_FEATURES, { items }))
142
- act(() => eb.emit(SET_FEATURES_SUPPRESSED))
143
- expect(result.current.items).toEqual(items)
144
- })
145
-
146
- it('a features update received while suppressed is still applied once restored', () => {
147
- const eb = makeEventBus()
148
- const { result } = renderHook(() => useFeatureItems(eb))
149
- act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
150
- act(() => eb.emit(SET_FEATURES, { items }))
151
- expect(result.current.items).toEqual([]) // still suppressed
152
- act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
153
- expect(result.current.items).toEqual(items)
154
- })
155
- })