@defra/interactive-map 0.0.43-alpha → 0.0.44-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +16 -8
  7. package/docs/plugins/draw.md +1 -1
  8. package/package.json +1 -1
  9. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  11. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  12. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  13. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  14. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  15. package/plugins/datasets/dist/css/index.css +1 -1
  16. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  17. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  18. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  19. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  20. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  21. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  22. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  23. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  24. package/plugins/draw/dist/esm/index.js +1 -1
  25. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  28. package/plugins/draw/dist/umd/index.js +1 -1
  29. package/plugins/draw/src/DrawInit.jsx +31 -26
  30. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  31. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  32. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  33. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  34. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  35. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  36. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  37. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  38. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +8 -2
  39. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +35 -0
  40. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  41. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  42. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  43. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  44. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  45. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  46. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  47. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  48. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  49. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  51. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  52. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  53. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  54. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  55. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  56. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  57. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  58. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  59. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  60. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  61. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  62. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  63. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  64. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  65. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  66. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  67. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  68. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  69. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  70. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +31 -17
  71. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  72. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  73. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  74. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  75. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  76. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  77. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  78. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  79. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  80. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +32 -57
  81. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +14 -13
  82. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  83. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  84. package/plugins/draw/src/events.js +4 -4
  85. package/plugins/draw/src/events.test.js +1 -1
  86. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  87. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  88. package/plugins/draw/src/utils/spatial.js +1 -31
  89. package/plugins/draw/src/utils/spatial.test.js +1 -24
  90. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  92. package/plugins/interact/dist/umd/index.js +1 -1
  93. package/plugins/interact/src/InteractInit.jsx +2 -2
  94. package/plugins/interact/src/InteractInit.test.js +3 -3
  95. package/plugins/interact/src/events.js +5 -0
  96. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  97. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  98. package/plugins/interact/src/hooks/useCrossHairVisibility.js +4 -4
  99. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  100. package/plugins/interact/src/hooks/{useMapItemList.js → useSpatialList.js} +87 -52
  101. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +152 -71
  102. package/plugins/interact/src/manifest.js +12 -1
  103. package/plugins/interact/src/manifest.test.js +20 -0
  104. package/plugins/interact/src/utils/spatial.js +1 -1
  105. package/providers/beta/openlayers/src/utils/queryFeatures.js +1 -1
  106. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  107. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  108. package/providers/maplibre/dist/umd/index.js +1 -1
  109. package/providers/maplibre/src/utils/labels.js +2 -2
  110. package/providers/maplibre/src/utils/labels.test.js +5 -5
  111. package/providers/maplibre/src/utils/spatial.js +0 -57
  112. package/providers/maplibre/src/utils/spatial.test.js +0 -27
  113. package/src/App/components/CrossHair/CrossHair.jsx +10 -4
  114. package/src/App/components/CrossHair/CrossHair.module.scss +16 -12
  115. package/src/App/components/CrossHair/CrossHair.test.jsx +9 -4
  116. package/src/App/components/Hints/Hints.jsx +3 -3
  117. package/src/App/components/Hints/Hints.test.jsx +3 -3
  118. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  119. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  120. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  121. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  122. package/src/App/components/Markers/Markers.jsx +2 -2
  123. package/src/App/components/Markers/Markers.test.jsx +3 -3
  124. package/src/App/components/Markers/SymbolMarker.test.jsx +2 -2
  125. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  126. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  127. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +3 -3
  128. package/src/App/components/Viewport/{Features.test.jsx → SpatialList.test.jsx} +60 -43
  129. package/src/App/components/Viewport/Viewport.jsx +20 -10
  130. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  131. package/src/App/hooks/useCrossHairAPI.js +5 -5
  132. package/src/App/hooks/useCrossHairAPI.test.js +5 -5
  133. package/src/App/hooks/useHintsAPI.js +5 -5
  134. package/src/App/hooks/useSpatialListFocus.js +296 -0
  135. package/src/App/hooks/{useFeatureFocus.test.js → useSpatialListFocus.test.js} +318 -163
  136. package/src/App/hooks/useSpatialListItems.js +51 -0
  137. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  138. package/src/App/initialiseApp.js +6 -3
  139. package/src/App/initialiseApp.test.js +11 -0
  140. package/src/App/registry/spatialListRegistry.js +104 -0
  141. package/src/App/registry/spatialListRegistry.test.js +218 -0
  142. package/src/App/store/AppProvider.jsx +3 -2
  143. package/src/config/appConfig.js +14 -14
  144. package/src/config/appConfig.test.js +19 -19
  145. package/src/config/defaults.js +2 -2
  146. package/src/config/events.js +8 -8
  147. package/src/scss/main.scss +2 -2
  148. package/src/test-utils.js +8 -0
  149. package/src/types.js +11 -9
  150. package/src/utils/findNearestItemInDirection.js +29 -0
  151. package/src/utils/findNearestItemInDirection.test.js +67 -0
  152. package/src/utils/globalAltShortcuts.js +12 -0
  153. package/src/utils/globalAltShortcuts.test.js +27 -0
  154. package/src/utils/spatialNavigate.js +49 -0
  155. package/src/utils/spatialNavigate.test.js +42 -0
  156. package/src/App/components/Viewport/Features.jsx +0 -49
  157. package/src/App/hooks/useFeatureFocus.js +0 -257
  158. package/src/App/hooks/useFeatureItems.js +0 -39
  159. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -0,0 +1,296 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { EVENTS } from '../../config/events.js'
3
+ import { findNearestItemInDirection } from '../../utils/findNearestItemInDirection.js'
4
+ import { spatialNavigate } from '../../utils/spatialNavigate.js'
5
+ import { stopIfGlobalAltKey } from '../../utils/globalAltShortcuts.js'
6
+
7
+ const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
8
+
9
+ // Whichever item is nearest the map's own center point on screen — the entry-point fallback
10
+ // below prefers this over the structurally-first item.
11
+ const findNearestToCenter = (items, centerScreenPoint) => {
12
+ if (!centerScreenPoint) {
13
+ return null
14
+ }
15
+ const positioned = items.filter(item => item.x != null && item.y != null)
16
+ if (!positioned.length) {
17
+ return null
18
+ }
19
+ const pixels = positioned.map(item => [item.x, item.y])
20
+ const idx = spatialNavigate([centerScreenPoint.x, centerScreenPoint.y], pixels, undefined)
21
+ return positioned[idx].id
22
+ }
23
+
24
+ const getNavigatedId = (id, key, items) => {
25
+ if (!items.length) {
26
+ return id
27
+ }
28
+ if (key === 'Home') {
29
+ return items[0].id
30
+ }
31
+ if (key === 'End') {
32
+ return items[items.length - 1].id
33
+ }
34
+ const idx = items.findIndex(item => item.id === id)
35
+ if (key === 'ArrowDown') {
36
+ return idx === -1 ? items[0].id : items[Math.min(idx + 1, items.length - 1)].id
37
+ }
38
+ return idx === -1 ? items[items.length - 1].id : items[Math.max(idx - 1, 0)].id
39
+ }
40
+
41
+ // ARIA listbox entry priority: first selected → last active (if still in list) → nearest the
42
+ // map center → first item (last resort, e.g. no items carry a screen position at all)
43
+ const resolveEntryId = (items, lastActiveId, selectedIds, centerScreenPoint) => {
44
+ const firstSelected = items.find(item => selectedIds.includes(item.id))
45
+ if (firstSelected) {
46
+ return firstSelected.id
47
+ }
48
+ if (lastActiveId && items.some(item => item.id === lastActiveId)) {
49
+ return lastActiveId
50
+ }
51
+ return findNearestToCenter(items, centerScreenPoint) ?? items[0].id
52
+ }
53
+
54
+ // Roving tabindex: moves real focus to the option matching id (via data-id, avoiding
55
+ // querySelector's CSS-escaping issues with arbitrary ids). Flags isInternalFocusMoveRef around
56
+ // the .focus() call so onFocus/onBlur can tell this apart from a real widget entry/exit.
57
+ const focusOption = (listboxEl, id, isInternalFocusMoveRef) => {
58
+ const el = Array.from(listboxEl?.children ?? []).find(li => li.dataset.id === String(id))
59
+ if (!el) {
60
+ return
61
+ }
62
+ isInternalFocusMoveRef.current = true
63
+ el.focus({ preventScroll: true })
64
+ isInternalFocusMoveRef.current = false
65
+ }
66
+
67
+ /**
68
+ * Keeps local selectedIds in sync with interact:selectionchange, and mirrors
69
+ * MAP_SET_ACTIVE_ITEM back into React state so the roving tabindex position stays current.
70
+ */
71
+ function useEventBusListeners ({ eventBus, lastActiveIdRef, setActiveItemId, setSelectedIds }) {
72
+ useEffect(() => {
73
+ if (!eventBus) {
74
+ return undefined
75
+ }
76
+ const handleSetActive = ({ id }) => {
77
+ if (id !== null) {
78
+ lastActiveIdRef.current = id
79
+ }
80
+ setActiveItemId(id)
81
+ }
82
+ const handleSelectionChange = ({ selectedFeatures = [], selectedMarkers = [] }) => {
83
+ setSelectedIds([...selectedFeatures.map(f => String(f.featureId)), ...selectedMarkers])
84
+ }
85
+ eventBus.on(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
86
+ eventBus.on('interact:selectionchange', handleSelectionChange)
87
+ return () => {
88
+ eventBus.off(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
89
+ eventBus.off('interact:selectionchange', handleSelectionChange)
90
+ }
91
+ }, [eventBus])
92
+ }
93
+
94
+ /**
95
+ * Re-picks the active item (ARIA priority order) when it drops out of the item list while
96
+ * focused — e.g. panned off screen — and moves real focus to it.
97
+ */
98
+ function useItemsRevalidation ({ items, eventBus, isFocusedRef, spatialListRef, isInternalFocusMoveRef, lastActiveIdRef, activeItemIdRef, selectedIdsRef, setActiveItemId, centerScreenPoint }) {
99
+ useEffect(() => {
100
+ if (!isFocusedRef.current) {
101
+ return
102
+ }
103
+ if (!items.length) {
104
+ setActiveItemId(null)
105
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: null })
106
+ return
107
+ }
108
+ if (items.some(item => item.id === activeItemIdRef.current)) {
109
+ return
110
+ }
111
+ const nextId = resolveEntryId(items, lastActiveIdRef.current, selectedIdsRef.current, centerScreenPoint)
112
+ lastActiveIdRef.current = nextId
113
+ setActiveItemId(nextId)
114
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: nextId })
115
+ focusOption(spatialListRef.current, nextId, isInternalFocusMoveRef)
116
+ }, [items]) // NOSONAR — eventBus/selectedIds consumed via refs to avoid spurious re-runs on selection change
117
+ }
118
+
119
+ /**
120
+ * Attaches keydown/keyup listeners to the listbox element for ARIA keyboard navigation:
121
+ * - ArrowUp/ArrowDown — move the active item sequentially, moving real focus (roving tabindex)
122
+ * - Alt+Arrow (any of the four) — move the active item spatially, to whichever item is
123
+ * nearest in the pressed direction on screen, same roving-tabindex move as above
124
+ * - Home/End — jump the active item to the first/last option
125
+ * - Enter/Space — confirm selection, emitting MAP_SELECT_ITEM
126
+ * - Escape — return focus to the map viewport
127
+ */
128
+ function useKeyboardNavigation ({ spatialListRef, viewportRef, items, eventBus, activeItemIdRef, lastActiveIdRef, setActiveItemId, isInternalFocusMoveRef, hints, currentHintRef }) {
129
+ useEffect(() => {
130
+ const listboxEl = spatialListRef.current
131
+ if (!listboxEl) {
132
+ return undefined
133
+ }
134
+ const moveTo = (newId) => {
135
+ lastActiveIdRef.current = newId
136
+ setActiveItemId(newId)
137
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: newId })
138
+ focusOption(listboxEl, newId, isInternalFocusMoveRef)
139
+ }
140
+ const handleEscape = () => {
141
+ if (currentHintRef.current) {
142
+ hints.dismiss()
143
+ } else {
144
+ viewportRef.current?.focus()
145
+ }
146
+ }
147
+ // Same modifier as the map's own label navigation, but moves list items instead of labels
148
+ // while this listbox has focus — the keyup listener below stops it bubbling to that binding.
149
+ const handleSpatialMove = (event) => {
150
+ moveTo(findNearestItemInDirection(items, activeItemIdRef.current, event.key))
151
+ }
152
+ const handleKeyDown = (event) => {
153
+ if (event.key === 'Escape') {
154
+ event.preventDefault()
155
+ event.stopPropagation()
156
+ handleEscape()
157
+ } else if (event.altKey && ARROW_KEYS.has(event.key)) {
158
+ // preventDefault here (on keydown) suppresses the browser's own Alt+Arrow
159
+ // history-navigation default.
160
+ event.preventDefault()
161
+ event.stopPropagation()
162
+ handleSpatialMove(event)
163
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
164
+ event.preventDefault()
165
+ event.stopPropagation()
166
+ moveTo(getNavigatedId(activeItemIdRef.current, event.key, items))
167
+ } else if (event.key === 'Enter' || event.key === ' ') {
168
+ event.preventDefault()
169
+ event.stopPropagation()
170
+ eventBus?.emit(EVENTS.MAP_SELECT_ITEM)
171
+ } else {
172
+ // No action
173
+ }
174
+ }
175
+ // Shadows global Alt+<key> shortcuts (src/utils/globalAltShortcuts.js) from bubbling to
176
+ // the app-wide handler while this listbox has focus.
177
+ const handleKeyUp = (event) => {
178
+ stopIfGlobalAltKey(event)
179
+ }
180
+ listboxEl.addEventListener('keydown', handleKeyDown)
181
+ listboxEl.addEventListener('keyup', handleKeyUp)
182
+ return () => {
183
+ listboxEl.removeEventListener('keydown', handleKeyDown)
184
+ listboxEl.removeEventListener('keyup', handleKeyUp)
185
+ }
186
+ }, [viewportRef, spatialListRef, items, eventBus])
187
+ }
188
+
189
+ /**
190
+ * Returns focus to the viewport when the user interacts with the map via pointer while the
191
+ * listbox is focused — clears its focus ring without dropping focus to nowhere.
192
+ */
193
+ function useMapInteractionBlur ({ viewportRef, spatialListRef, isFocusedRef }) {
194
+ useEffect(() => {
195
+ const el = viewportRef.current
196
+ if (!el) {
197
+ return undefined
198
+ }
199
+ const handlePointerDown = (event) => {
200
+ if (isFocusedRef.current && !spatialListRef.current?.contains(event.target)) {
201
+ viewportRef.current?.focus()
202
+ }
203
+ }
204
+ el.addEventListener('pointerdown', handlePointerDown)
205
+ return () => { el.removeEventListener('pointerdown', handlePointerDown) }
206
+ }, [viewportRef, spatialListRef])
207
+ }
208
+
209
+ /**
210
+ * Manages roving-tabindex focus state for the keyboard-accessible feature list. On focus, sets
211
+ * activeItemId via ARIA priority order (see resolveEntryId); on blur, clears it. Revalidates
212
+ * when the item list changes (e.g. after a map pan) so it never points to a stale item.
213
+ *
214
+ * @param {{ viewportRef: React.RefObject, spatialListRef: React.RefObject, items: Array, eventBus: object, centerScreenPoint: {x: number, y: number}|null }} params
215
+ * @returns {{ activeItemId: string|null, tabbableId: string|null, selectedIds: string[], onFocus: Function, onBlur: Function, selectItem: Function }}
216
+ */
217
+ export function useSpatialListFocus ({ viewportRef, spatialListRef, items = [], eventBus, hints, centerScreenPoint }) {
218
+ const [activeItemId, setActiveItemId] = useState(null)
219
+ const [selectedIds, setSelectedIds] = useState([])
220
+
221
+ const isFocusedRef = useRef(false)
222
+ const lastActiveIdRef = useRef(null) // preserved across blur; restores position on re-focus
223
+ const activeItemIdRef = useRef(null) // always-current for keydown closure
224
+ const selectedIdsRef = useRef([]) // always-current for items-change effect
225
+ const currentHintRef = useRef(null)
226
+ const isInternalFocusMoveRef = useRef(false) // true only while focusOption()'s own .focus() call is in flight
227
+
228
+ useEffect(() => {
229
+ return hints.subscribe((hint) => {
230
+ currentHintRef.current = hint
231
+ })
232
+ }, [hints])
233
+
234
+ // Always-current mirrors for values read from event-listener closures (not React re-renders) —
235
+ // assigned directly during render, same convention as useVisibleGeometry.js's latestRef.
236
+ activeItemIdRef.current = activeItemId
237
+ selectedIdsRef.current = selectedIds
238
+
239
+ useEventBusListeners({ eventBus, lastActiveIdRef, setActiveItemId, setSelectedIds })
240
+ useItemsRevalidation({ items, eventBus, isFocusedRef, spatialListRef, isInternalFocusMoveRef, lastActiveIdRef, activeItemIdRef, selectedIdsRef, setActiveItemId, centerScreenPoint })
241
+ useKeyboardNavigation({ spatialListRef, viewportRef, items, eventBus, activeItemIdRef, lastActiveIdRef, setActiveItemId, isInternalFocusMoveRef, hints, currentHintRef })
242
+ useMapInteractionBlur({ viewportRef, spatialListRef, isFocusedRef })
243
+
244
+ // Resting roving-tabindex position — where Tab lands before the list has ever had real focus.
245
+ // Deliberately never selection-driven (unlike onFocus's own resolution below): sticks to the
246
+ // last established keyboard position so an unrelated selection change elsewhere doesn't
247
+ // relocate it.
248
+ let tabbableId = null
249
+ if (items.length) {
250
+ const hasEstablishedPosition = lastActiveIdRef.current && items.some(item => item.id === lastActiveIdRef.current)
251
+ tabbableId = hasEstablishedPosition ? lastActiveIdRef.current : (findNearestToCenter(items, centerScreenPoint) ?? items[0].id)
252
+ }
253
+
254
+ const onFocus = () => {
255
+ isFocusedRef.current = true
256
+ // Ignore focus moving between sibling options (roving tabindex) — the keydown/selectItem
257
+ // path that moved it already resolved and emitted the correct id; re-resolving here would
258
+ // clobber that emit.
259
+ if (isInternalFocusMoveRef.current) {
260
+ return
261
+ }
262
+ if (!items.length) {
263
+ return
264
+ }
265
+ // A real Tab-in prefers a selected item over the remembered position (native listbox
266
+ // behaviour) — unlike tabbableId, which deliberately ignores selection.
267
+ const id = resolveEntryId(items, lastActiveIdRef.current, selectedIds, centerScreenPoint)
268
+ lastActiveIdRef.current = id
269
+ setActiveItemId(id)
270
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id })
271
+ // Real focus may have landed on a different option than this resolves to (tabbableId and
272
+ // resolveEntryId can legitimately disagree) — move it to match so it never disagrees with activeItemId.
273
+ focusOption(spatialListRef.current, id, isInternalFocusMoveRef)
274
+ }
275
+
276
+ const onBlur = () => {
277
+ // Ignore focus moving between sibling options (roving tabindex) — not a real exit.
278
+ if (isInternalFocusMoveRef.current) {
279
+ return
280
+ }
281
+ isFocusedRef.current = false
282
+ setActiveItemId(null)
283
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: null })
284
+ }
285
+
286
+ // Mirrors keyboard Enter/Space: make the clicked item active, focus it, then confirm selection.
287
+ const selectItem = (id) => {
288
+ lastActiveIdRef.current = id
289
+ setActiveItemId(id)
290
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id })
291
+ focusOption(spatialListRef.current, id, isInternalFocusMoveRef)
292
+ eventBus?.emit(EVENTS.MAP_SELECT_ITEM)
293
+ }
294
+
295
+ return { activeItemId, tabbableId, selectedIds, onFocus, onBlur, selectItem }
296
+ }