@defra/interactive-map 0.0.22-alpha → 0.0.24-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 (242) hide show
  1. package/assets/images/light-raster-thumb.jpg +0 -0
  2. package/assets/images/outdoor-raster-thumb.jpg +0 -0
  3. package/assets/images/road-raster-thumb.jpg +0 -0
  4. package/dist/css/index.css +1 -1
  5. package/dist/esm/im-core.js +1 -1
  6. package/dist/esm/im-shell.js +1 -1
  7. package/dist/umd/im-core.js +1 -1
  8. package/dist/umd/index.js +1 -1
  9. package/docs/api/button-definition.md +39 -0
  10. package/docs/api/context.md +4 -4
  11. package/docs/api/map-style-config.md +11 -2
  12. package/docs/api/marker-config.md +38 -7
  13. package/docs/api/panel-definition.md +7 -3
  14. package/docs/api/symbol-config.md +10 -26
  15. package/docs/api/symbol-registry.md +28 -13
  16. package/docs/api.md +24 -2
  17. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  18. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  19. package/docs/examples/index.mdx +12 -0
  20. package/docs/examples/toggle-marker-label.mdx +89 -0
  21. package/docs/plugins/interact.md +4 -11
  22. package/docs/plugins/search.md +70 -0
  23. package/docusaurus.config.cjs +2 -1
  24. package/package.json +23 -9
  25. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  26. package/plugins/beta/datasets/dist/css/index.css +2 -2
  27. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  28. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  29. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  31. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  32. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  33. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  34. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  35. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  36. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  37. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  38. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  39. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  40. package/plugins/beta/draw-ml/dist/css/index.css +1 -21
  41. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  42. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  43. package/plugins/beta/draw-ml/src/api/editFeature.js +9 -4
  44. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +11 -3
  45. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  46. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  47. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  48. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  49. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  50. package/plugins/beta/use-location/src/manifest.js +1 -0
  51. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  53. package/plugins/interact/dist/umd/index.js +1 -1
  54. package/plugins/interact/src/InteractInit.jsx +23 -55
  55. package/plugins/interact/src/InteractInit.test.js +58 -80
  56. package/plugins/interact/src/defaults.js +1 -3
  57. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  58. package/plugins/interact/src/hooks/useAttachEvents.test.js +68 -0
  59. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  60. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  61. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  62. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  63. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  64. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  65. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  66. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  67. package/plugins/interact/src/index.js +0 -2
  68. package/plugins/interact/src/index.test.js +0 -2
  69. package/plugins/interact/src/manifest.js +37 -6
  70. package/plugins/interact/src/reducer.js +8 -4
  71. package/plugins/interact/src/reducer.test.js +18 -3
  72. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  73. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  74. package/plugins/interact/src/utils/spatial.js +0 -15
  75. package/plugins/interact/src/utils/spatial.test.js +1 -13
  76. package/plugins/search/dist/css/index.css +1 -1
  77. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  78. package/plugins/search/dist/esm/index.js +1 -1
  79. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  80. package/plugins/search/dist/umd/index.js +1 -1
  81. package/plugins/search/src/Search.jsx +2 -1
  82. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  83. package/plugins/search/src/components/Form/Form.jsx +11 -7
  84. package/plugins/search/src/components/Form/Form.test.jsx +23 -1
  85. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  86. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  87. package/plugins/search/src/datasets.js +2 -2
  88. package/plugins/search/src/datasets.test.js +1 -1
  89. package/plugins/search/src/defaults.js +7 -1
  90. package/plugins/search/src/events/fetchSuggestions.js +13 -10
  91. package/plugins/search/src/events/fetchSuggestions.test.js +14 -1
  92. package/plugins/search/src/events/formHandlers.js +2 -4
  93. package/plugins/search/src/events/formHandlers.test.js +3 -3
  94. package/plugins/search/src/events/inputHandlers.js +1 -1
  95. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  96. package/plugins/search/src/events/suggestionHandlers.js +2 -1
  97. package/plugins/search/src/events/suggestionHandlers.test.js +7 -1
  98. package/plugins/search/src/index.js +3 -7
  99. package/plugins/search/src/reducer.js +10 -5
  100. package/plugins/search/src/reducer.test.js +2 -1
  101. package/plugins/search/src/search.scss +1 -0
  102. package/plugins/search/src/utils/parseOsNamesResults.js +3 -3
  103. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  104. package/providers/beta/esri/src/esriProvider.js +4 -0
  105. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -0
  106. package/providers/beta/openlayers/dist/esm/index.js +1 -0
  107. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +2 -0
  108. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js.LICENSE.txt +1 -0
  109. package/providers/beta/openlayers/dist/umd/index.js +2 -0
  110. package/providers/beta/openlayers/dist/umd/index.js.LICENSE.txt +1 -0
  111. package/providers/beta/openlayers/src/appEvents.js +50 -0
  112. package/providers/beta/openlayers/src/appEvents.test.js +140 -0
  113. package/providers/beta/openlayers/src/defaults.js +36 -0
  114. package/providers/beta/openlayers/src/index.js +21 -0
  115. package/providers/beta/openlayers/src/mapEvents.js +132 -0
  116. package/providers/beta/openlayers/src/mapEvents.test.js +197 -0
  117. package/providers/beta/openlayers/src/openlayersProvider.js +234 -0
  118. package/providers/beta/openlayers/src/openlayersProvider.test.js +331 -0
  119. package/providers/beta/openlayers/src/utils/spatial.js +142 -0
  120. package/providers/beta/openlayers/src/utils/spatial.test.js +117 -0
  121. package/providers/beta/openlayers/src/utils/zoom.js +26 -0
  122. package/providers/beta/openlayers/src/utils/zoom.test.js +23 -0
  123. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  124. package/providers/maplibre/dist/esm/index.js +1 -1
  125. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  126. package/providers/maplibre/dist/umd/index.js +1 -1
  127. package/providers/maplibre/src/index.js +1 -1
  128. package/providers/maplibre/src/index.test.js +9 -14
  129. package/providers/maplibre/src/mapEvents.js +11 -5
  130. package/providers/maplibre/src/mapEvents.test.js +14 -3
  131. package/providers/maplibre/src/maplibreProvider.js +18 -11
  132. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  133. package/providers/maplibre/src/utils/highlightFeatures.js +112 -57
  134. package/providers/maplibre/src/utils/highlightFeatures.test.js +179 -51
  135. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  136. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  137. package/providers/maplibre/src/utils/patternImages.js +22 -19
  138. package/providers/maplibre/src/utils/patternImages.test.js +146 -167
  139. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  140. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  141. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  142. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  143. package/rollup.esm.mjs +14 -3
  144. package/src/App/components/Actions/Actions.module.scss +1 -0
  145. package/src/App/components/Actions/Actions.test.jsx +17 -4
  146. package/src/App/components/Hints/Hints.jsx +52 -0
  147. package/src/App/components/Hints/Hints.module.scss +37 -0
  148. package/src/App/components/Hints/Hints.test.jsx +104 -0
  149. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  150. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  151. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  152. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  153. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  154. package/src/App/components/Markers/Markers.jsx +44 -75
  155. package/src/App/components/Markers/Markers.module.scss +58 -0
  156. package/src/App/components/Markers/Markers.test.jsx +90 -113
  157. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  158. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  159. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  160. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  161. package/src/App/components/Panel/Panel.jsx +1 -1
  162. package/src/App/components/Panel/Panel.module.scss +1 -0
  163. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  164. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  165. package/src/App/components/Tabs/Tabs.jsx +68 -0
  166. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  167. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  168. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  169. package/src/App/components/Viewport/Features.jsx +35 -0
  170. package/src/App/components/Viewport/Features.module.scss +31 -0
  171. package/src/App/components/Viewport/Features.test.jsx +124 -0
  172. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  173. package/src/App/components/Viewport/Viewport.jsx +35 -29
  174. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  175. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  176. package/src/App/controls/keyboardActions.js +9 -3
  177. package/src/App/controls/keyboardActions.test.js +122 -87
  178. package/src/App/controls/keyboardShortcuts.js +1 -14
  179. package/src/App/hooks/useFeatureFocus.js +199 -0
  180. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  181. package/src/App/hooks/useFeatureItems.js +30 -0
  182. package/src/App/hooks/useFeatureItems.test.js +98 -0
  183. package/src/App/hooks/useFocusVisible.js +4 -7
  184. package/src/App/hooks/useFocusVisible.test.js +6 -6
  185. package/src/App/hooks/useKeyboardHint.js +9 -34
  186. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  187. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  188. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  189. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  190. package/src/App/hooks/useLayoutMeasurements.test.js +30 -1
  191. package/src/App/hooks/useMarkerCursor.js +33 -0
  192. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  193. package/src/App/hooks/useMarkersAPI.js +32 -9
  194. package/src/App/hooks/useMarkersAPI.test.js +163 -94
  195. package/src/App/hooks/useModalPanelBehaviour.test.js +2 -1
  196. package/src/App/layout/Layout.jsx +2 -0
  197. package/src/App/layout/Layout.test.jsx +3 -0
  198. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  199. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  200. package/src/App/renderer/mapButtons.js +5 -1
  201. package/src/App/renderer/mapButtons.test.js +37 -1
  202. package/src/App/store/AppProvider.test.jsx +5 -0
  203. package/src/App/store/MapProvider.test.jsx +8 -1
  204. package/src/App/store/ServiceProvider.jsx +4 -1
  205. package/src/App/store/ServiceProvider.test.jsx +11 -0
  206. package/src/App/store/appActionsMap.js +5 -1
  207. package/src/App/store/appActionsMap.test.js +13 -0
  208. package/src/App/store/appReducer.js +1 -0
  209. package/src/App/store/mapActionsMap.js +2 -1
  210. package/src/InteractiveMap/InteractiveMap.js +12 -0
  211. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  212. package/src/config/appConfig.js +3 -1
  213. package/src/config/appConfig.test.js +4 -0
  214. package/src/config/defaults.js +1 -1
  215. package/src/config/events.js +8 -0
  216. package/src/config/mapTheme.js +8 -4
  217. package/src/config/symbolConfig.js +23 -16
  218. package/src/scss/main.scss +3 -0
  219. package/src/scss/tools/_border-focus.scss +0 -1
  220. package/src/services/announcer.test.js +5 -7
  221. package/src/services/eventBus.js +15 -0
  222. package/src/services/eventBus.test.js +11 -0
  223. package/src/services/hints.js +47 -0
  224. package/src/services/hints.test.js +110 -0
  225. package/src/services/patternRegistry.js +69 -1
  226. package/src/services/patternRegistry.test.js +112 -1
  227. package/src/services/reverseGeocode.js +4 -0
  228. package/src/services/reverseGeocode.test.js +11 -1
  229. package/src/services/symbolRegistry.js +152 -17
  230. package/src/services/symbolRegistry.test.js +141 -31
  231. package/src/types.js +71 -14
  232. package/src/utils/constrainKeyboardFocus.js +5 -5
  233. package/src/utils/detectInterfaceType.js +27 -5
  234. package/src/utils/detectInterfaceType.test.js +29 -3
  235. package/src/utils/patternUtils.js +4 -67
  236. package/src/utils/patternUtils.test.js +1 -123
  237. package/src/utils/symbolUtils.js +11 -13
  238. package/src/utils/symbolUtils.test.js +18 -26
  239. package/webpack.dev.mjs +6 -1
  240. package/webpack.umd.mjs +1 -0
  241. package/plugins/interact/dist/css/index.css +0 -1
  242. package/plugins/interact/src/interact.scss +0 -24
@@ -0,0 +1,199 @@
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
+ const idx = items.findIndex(item => item.id === id)
9
+ if (key === 'ArrowDown') {
10
+ return idx === -1 ? items[0].id : items[Math.min(idx + 1, items.length - 1)].id
11
+ }
12
+ return idx === -1 ? items[items.length - 1].id : items[Math.max(idx - 1, 0)].id
13
+ }
14
+
15
+ // ARIA listbox entry priority: first selected → last active (if still in list) → first item
16
+ const resolveEntryId = (items, lastActiveId, selectedIds) => {
17
+ const firstSelected = items.find(item => selectedIds.includes(item.id))
18
+ if (firstSelected) {
19
+ return firstSelected.id
20
+ }
21
+ if (lastActiveId && items.some(item => item.id === lastActiveId)) {
22
+ return lastActiveId
23
+ }
24
+ return items[0].id
25
+ }
26
+
27
+ /**
28
+ * Keeps local selectedIds in sync with interact:selectionchange, and mirrors
29
+ * MAP_SET_ACTIVE_FEATURE back into React state so aria-activedescendant stays current.
30
+ * Also tracks the last non-null active id so it can be restored on re-focus.
31
+ */
32
+ function useEventBusListeners ({ eventBus, lastActiveIdRef, setActiveFeatureId, setSelectedIds }) {
33
+ useEffect(() => {
34
+ if (!eventBus) {
35
+ return undefined
36
+ }
37
+ const handleSetActive = ({ id }) => {
38
+ if (id !== null) {
39
+ lastActiveIdRef.current = id
40
+ }
41
+ setActiveFeatureId(id)
42
+ }
43
+ const handleSelectionChange = ({ selectedFeatures = [], selectedMarkers = [] }) => {
44
+ setSelectedIds([...selectedFeatures.map(f => String(f.featureId)), ...selectedMarkers])
45
+ }
46
+ eventBus.on(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
47
+ eventBus.on('interact:selectionchange', handleSelectionChange)
48
+ return () => {
49
+ eventBus.off(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
50
+ eventBus.off('interact:selectionchange', handleSelectionChange)
51
+ }
52
+ }, [eventBus])
53
+ }
54
+
55
+ /**
56
+ * Revalidates aria-activedescendant when the item list changes while the listbox is focused.
57
+ * If the current active item is no longer in the list (e.g. panned off screen), picks a new one
58
+ * using ARIA priority order: first selected → last active position → first item.
59
+ * Reads eventBus and selectedIds via refs to avoid spurious re-runs on selection change.
60
+ */
61
+ function useItemsRevalidation ({ items, eventBus, isFocusedRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId }) {
62
+ useEffect(() => {
63
+ if (!isFocusedRef.current) {
64
+ return
65
+ }
66
+ if (!items.length) {
67
+ setActiveFeatureId(null)
68
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: null })
69
+ return
70
+ }
71
+ if (items.some(item => item.id === activeFeatureIdRef.current)) {
72
+ return
73
+ }
74
+ const nextId = resolveEntryId(items, lastActiveIdRef.current, selectedIdsRef.current)
75
+ lastActiveIdRef.current = nextId
76
+ setActiveFeatureId(nextId)
77
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: nextId })
78
+ }, [items]) // NOSONAR — eventBus/selectedIds consumed via refs to avoid spurious re-runs on selection change
79
+ }
80
+
81
+ /**
82
+ * Attaches a keydown listener to the listbox element for ARIA keyboard navigation:
83
+ * - ArrowUp/ArrowDown — move the active item, emitting MAP_SET_ACTIVE_FEATURE
84
+ * - Enter/Space — confirm selection, emitting MAP_SELECT_FEATURE
85
+ * - Escape — return focus to the map viewport
86
+ */
87
+ function useKeyboardNavigation ({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, hints, currentHintRef }) {
88
+ useEffect(() => {
89
+ const listboxEl = featuresRef.current
90
+ if (!listboxEl) {
91
+ return undefined
92
+ }
93
+ const handleKeyDown = (event) => {
94
+ if (event.key === 'Escape') {
95
+ event.preventDefault()
96
+ event.stopPropagation()
97
+ if (currentHintRef.current) {
98
+ hints.dismiss()
99
+ } else {
100
+ viewportRef.current?.focus()
101
+ }
102
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
103
+ event.preventDefault()
104
+ event.stopPropagation()
105
+ const newId = getNavigatedId(activeFeatureIdRef.current, event.key, items)
106
+ lastActiveIdRef.current = newId
107
+ setActiveFeatureId(newId)
108
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: newId })
109
+ } else if (event.key === 'Enter' || event.key === ' ') {
110
+ event.preventDefault()
111
+ event.stopPropagation()
112
+ eventBus?.emit(EVENTS.MAP_SELECT_FEATURE)
113
+ } else {
114
+ // No action
115
+ }
116
+ }
117
+ listboxEl.addEventListener('keydown', handleKeyDown)
118
+ return () => { listboxEl.removeEventListener('keydown', handleKeyDown) }
119
+ }, [viewportRef, featuresRef, items, eventBus])
120
+ }
121
+
122
+ /**
123
+ * Clears listbox focus whenever the user interacts with the map via pointer (mouse or touch).
124
+ * Any pointerdown outside the listbox element moves focus to the viewport, removing the
125
+ * visible focus ring from the listbox without dropping focus to an unknown location.
126
+ */
127
+ function useMapInteractionBlur ({ viewportRef, featuresRef, isFocusedRef }) {
128
+ useEffect(() => {
129
+ const el = viewportRef.current
130
+ if (!el) {
131
+ return undefined
132
+ }
133
+ const handlePointerDown = (event) => {
134
+ if (isFocusedRef.current && !featuresRef.current?.contains(event.target)) {
135
+ viewportRef.current?.focus()
136
+ }
137
+ }
138
+ el.addEventListener('pointerdown', handlePointerDown)
139
+ return () => { el.removeEventListener('pointerdown', handlePointerDown) }
140
+ }, [viewportRef, featuresRef])
141
+ }
142
+
143
+ /**
144
+ * Manages ARIA listbox focus state for the keyboard-accessible feature list.
145
+ *
146
+ * On focus, sets aria-activedescendant following ARIA priority order:
147
+ * 1. First selected item (if any)
148
+ * 2. Last active position (if still in the list)
149
+ * 3. First item (fallback)
150
+ *
151
+ * On blur, clears the active descendant. Revalidates automatically when the item list
152
+ * changes (e.g. after a map pan) so the active id never points to a stale item.
153
+ *
154
+ * @param {{ viewportRef: React.RefObject, featuresRef: React.RefObject, items: Array, eventBus: object }} params
155
+ * @returns {{ activeFeatureId: string|null, selectedIds: string[], onFocus: Function, onBlur: Function }}
156
+ */
157
+ export function useFeatureFocus ({ viewportRef, featuresRef, items = [], eventBus, hints }) {
158
+ const [activeFeatureId, setActiveFeatureId] = useState(null)
159
+ const [selectedIds, setSelectedIds] = useState([])
160
+
161
+ const isFocusedRef = useRef(false)
162
+ const lastActiveIdRef = useRef(null) // preserved across blur; restores position on re-focus
163
+ const activeFeatureIdRef = useRef(null) // always-current for keydown closure
164
+ const selectedIdsRef = useRef([]) // always-current for items-change effect
165
+ const currentHintRef = useRef(null)
166
+
167
+ useEffect(() => {
168
+ return hints.subscribe((hint) => {
169
+ currentHintRef.current = hint
170
+ })
171
+ }, [hints])
172
+
173
+ useEffect(() => { activeFeatureIdRef.current = activeFeatureId }, [activeFeatureId])
174
+ useEffect(() => { selectedIdsRef.current = selectedIds }, [selectedIds])
175
+
176
+ useEventBusListeners({ eventBus, lastActiveIdRef, setActiveFeatureId, setSelectedIds })
177
+ useItemsRevalidation({ items, eventBus, isFocusedRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId })
178
+ useKeyboardNavigation({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, hints, currentHintRef })
179
+ useMapInteractionBlur({ viewportRef, featuresRef, isFocusedRef })
180
+
181
+ const onFocus = () => {
182
+ isFocusedRef.current = true
183
+ if (!items.length) {
184
+ return
185
+ }
186
+ const id = resolveEntryId(items, lastActiveIdRef.current, selectedIds)
187
+ lastActiveIdRef.current = id
188
+ setActiveFeatureId(id)
189
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id })
190
+ }
191
+
192
+ const onBlur = () => {
193
+ isFocusedRef.current = false
194
+ setActiveFeatureId(null)
195
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: null })
196
+ }
197
+
198
+ return { activeFeatureId, selectedIds, onFocus, onBlur }
199
+ }