@defra/interactive-map 0.0.43-fmp-experimental → 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 (275) hide show
  1. package/assets/templates/add-polygons.njk +5 -1
  2. package/assets/templates/add-symbols.njk +5 -1
  3. package/dist/css/index.css +1 -1
  4. package/dist/esm/im-core.js +1 -1
  5. package/dist/esm/im-shell.js +1 -1
  6. package/dist/umd/im-core.js +1 -1
  7. package/dist/umd/index.js +1 -1
  8. package/docs/api/context.md +24 -0
  9. package/docs/api/keyboard-shortcut-definition.md +102 -0
  10. package/docs/api.md +18 -8
  11. package/docs/building-a-plugin.md +1 -0
  12. package/docs/plugins/draw.md +2 -2
  13. package/docs/plugins/plugin-manifest.md +9 -0
  14. package/package.json +4 -2
  15. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  16. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -0
  17. package/plugins/beta/draw-es/src/events.js +17 -8
  18. package/plugins/beta/draw-es/src/reducer.js +8 -1
  19. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  20. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ol/dist/css/index.css +1 -13
  22. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  23. package/plugins/beta/frame/dist/css/index.css +1 -11
  24. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  25. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  26. package/plugins/beta/frame/src/Frame.jsx +28 -2
  27. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  28. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  29. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  30. package/plugins/beta/scale-bar/dist/css/index.css +1 -8
  31. package/plugins/datasets/dist/css/5648.index.css +1 -5
  32. package/plugins/datasets/dist/css/index.css +1 -1
  33. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  34. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  35. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  36. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  37. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  38. package/plugins/datasets/src/registry/dataset.js +8 -12
  39. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  40. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  41. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  42. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  43. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  44. package/plugins/draw/dist/esm/index.js +1 -1
  45. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  46. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  47. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  48. package/plugins/draw/dist/umd/index.js +1 -1
  49. package/plugins/draw/src/DrawInit.jsx +41 -26
  50. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  51. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  52. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  53. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  54. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  55. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +23 -2
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +53 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  63. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  64. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  65. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  66. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  67. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  68. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  69. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  84. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  85. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  86. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  87. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  88. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  89. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  90. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +40 -17
  91. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  92. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  93. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  94. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  95. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  96. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  97. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  98. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  99. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  100. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +40 -54
  101. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +32 -13
  102. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  103. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  104. package/plugins/draw/src/events.js +4 -4
  105. package/plugins/draw/src/events.test.js +1 -1
  106. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  107. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  108. package/plugins/draw/src/manifest.js +4 -1
  109. package/plugins/draw/src/manifest.test.js +19 -7
  110. package/plugins/draw/src/utils/spatial.js +1 -31
  111. package/plugins/draw/src/utils/spatial.test.js +1 -24
  112. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  113. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  114. package/plugins/interact/dist/umd/index.js +1 -1
  115. package/plugins/interact/src/InteractInit.jsx +2 -2
  116. package/plugins/interact/src/InteractInit.test.js +3 -3
  117. package/plugins/interact/src/events.js +11 -2
  118. package/plugins/interact/src/events.test.js +32 -0
  119. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  120. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  121. package/plugins/interact/src/hooks/useCrossHairVisibility.js +9 -4
  122. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  123. package/plugins/interact/src/hooks/useSpatialList.js +251 -0
  124. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +216 -70
  125. package/plugins/interact/src/manifest.js +12 -1
  126. package/plugins/interact/src/manifest.test.js +20 -0
  127. package/plugins/interact/src/utils/spatial.js +24 -1
  128. package/plugins/interact/src/utils/spatial.test.js +26 -1
  129. package/plugins/map-key/dist/css/index.css +1 -106
  130. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  131. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  132. package/plugins/map-key/dist/umd/index.js +1 -1
  133. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  134. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  135. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  136. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  137. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  138. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  139. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  140. package/plugins/map-key/src/manifest.js +1 -1
  141. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  142. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  143. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  144. package/plugins/menu/dist/css/index.css +1 -80
  145. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  146. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  147. package/plugins/menu/dist/umd/index.js +1 -1
  148. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  149. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  150. package/plugins/menu/src/initialise/createMenu.js +3 -1
  151. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  152. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  153. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  154. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  155. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  156. package/plugins/menu/src/reducers/pluginState.js +1 -6
  157. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  158. package/plugins/search/dist/css/index.css +1 -1
  159. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  160. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  161. package/plugins/search/src/manifest.js +5 -9
  162. package/plugins/search/src/manifest.test.js +5 -0
  163. package/providers/beta/esri/dist/css/index.css +1 -34
  164. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  165. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  166. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  167. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  168. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  169. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  170. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  171. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  172. package/providers/maplibre/dist/umd/index.js +1 -1
  173. package/providers/maplibre/src/defaults.test.js +26 -0
  174. package/providers/maplibre/src/utils/labels.js +2 -2
  175. package/providers/maplibre/src/utils/labels.test.js +5 -5
  176. package/providers/maplibre/src/utils/spatial.js +0 -57
  177. package/providers/maplibre/src/utils/spatial.test.js +0 -27
  178. package/rollup.esm.mjs +2 -1
  179. package/scripts/publish-package.sh +36 -0
  180. package/src/App/App.jsx +1 -1
  181. package/src/App/App.test.jsx +6 -1
  182. package/src/App/components/CrossHair/CrossHair.jsx +25 -11
  183. package/src/App/components/CrossHair/CrossHair.module.scss +37 -12
  184. package/src/App/components/CrossHair/CrossHair.test.jsx +40 -11
  185. package/src/App/components/Hints/Hints.jsx +3 -3
  186. package/src/App/components/Hints/Hints.test.jsx +3 -3
  187. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  188. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  189. package/src/App/components/MapButton/MapButton.jsx +19 -7
  190. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  191. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  192. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  193. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  194. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  195. package/src/App/components/Markers/Markers.jsx +2 -2
  196. package/src/App/components/Markers/Markers.test.jsx +3 -3
  197. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  198. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  199. package/src/App/components/Panel/Panel.jsx +19 -20
  200. package/src/App/components/Panel/Panel.module.scss +5 -0
  201. package/src/App/components/Panel/Panel.test.jsx +69 -4
  202. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  203. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  204. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  205. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  206. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  207. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +6 -6
  208. package/src/App/components/Viewport/SpatialList.test.jsx +285 -0
  209. package/src/App/components/Viewport/Viewport.jsx +21 -11
  210. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  211. package/src/App/controls/keyboardMappings.js +7 -2
  212. package/src/App/controls/keyboardMappings.test.js +21 -0
  213. package/src/App/controls/keyboardShortcuts.js +16 -2
  214. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  215. package/src/App/hooks/useCrossHairAPI.js +29 -1
  216. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  217. package/src/App/hooks/useHintsAPI.js +5 -5
  218. package/src/App/hooks/useInterfaceAPI.js +2 -1
  219. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  220. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  221. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  222. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  223. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  224. package/src/App/hooks/useSpatialListFocus.js +296 -0
  225. package/src/App/hooks/useSpatialListFocus.test.js +957 -0
  226. package/src/App/hooks/useSpatialListItems.js +51 -0
  227. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  228. package/src/App/initialiseApp.js +6 -3
  229. package/src/App/initialiseApp.test.js +11 -0
  230. package/src/App/layout/Layout.jsx +4 -2
  231. package/src/App/layout/Layout.test.jsx +21 -0
  232. package/src/App/layout/layout.module.scss +7 -1
  233. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  234. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  235. package/src/App/registry/pluginRegistry.js +13 -0
  236. package/src/App/registry/pluginRegistry.test.js +15 -0
  237. package/src/App/registry/spatialListRegistry.js +104 -0
  238. package/src/App/registry/spatialListRegistry.test.js +218 -0
  239. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  240. package/src/App/renderer/mapButtons.js +4 -0
  241. package/src/App/renderer/mapButtons.test.js +20 -0
  242. package/src/App/renderer/mapPanels.js +33 -53
  243. package/src/App/renderer/mapPanels.test.js +29 -4
  244. package/src/App/renderer/slotHelpers.js +33 -0
  245. package/src/App/renderer/slotHelpers.test.js +79 -2
  246. package/src/App/store/AppProvider.jsx +3 -2
  247. package/src/App/store/ServiceProvider.jsx +5 -3
  248. package/src/App/store/ServiceProvider.test.jsx +20 -1
  249. package/src/InteractiveMap/InteractiveMap.js +13 -2
  250. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  251. package/src/config/appConfig.js +16 -16
  252. package/src/config/appConfig.test.js +19 -19
  253. package/src/config/defaults.js +2 -2
  254. package/src/config/events.js +8 -8
  255. package/src/scss/main.scss +2 -2
  256. package/src/test-utils.js +8 -0
  257. package/src/types.js +51 -9
  258. package/src/utils/findNearestItemInDirection.js +29 -0
  259. package/src/utils/findNearestItemInDirection.test.js +67 -0
  260. package/src/utils/getPanelElementId.js +4 -0
  261. package/src/utils/getPanelElementId.test.js +11 -0
  262. package/src/utils/getPanelRole.js +22 -0
  263. package/src/utils/getPanelRole.test.js +56 -0
  264. package/src/utils/globalAltShortcuts.js +12 -0
  265. package/src/utils/globalAltShortcuts.test.js +27 -0
  266. package/src/utils/spatialNavigate.js +49 -0
  267. package/src/utils/spatialNavigate.test.js +42 -0
  268. package/webpack.umd.mjs +6 -1
  269. package/plugins/interact/src/hooks/useMapItemList.js +0 -187
  270. package/src/App/components/Viewport/Features.jsx +0 -35
  271. package/src/App/components/Viewport/Features.test.jsx +0 -124
  272. package/src/App/hooks/useFeatureFocus.js +0 -199
  273. package/src/App/hooks/useFeatureFocus.test.js +0 -635
  274. package/src/App/hooks/useFeatureItems.js +0 -39
  275. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -5,6 +5,71 @@ import { useConfig } from '../store/configContext.js'
5
5
  import { useApp } from '../store/appContext.js'
6
6
  import { useService } from '../store/serviceContext.js'
7
7
 
8
+ const normalizeKey = (e) => {
9
+ let key
10
+
11
+ // Use e.code for letters to avoid 'dead' keys with Alt/AltGr
12
+ if (/^Key[A-Z]$/.test(e.code)) {
13
+ key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I"
14
+ } else {
15
+ key = e.key // works for arrows, numpad, punctuation
16
+ }
17
+
18
+ // Normalize numpad add/subtract to symbols
19
+ if (key === 'Add' || key === 'NumpadAdd') {
20
+ key = '+'
21
+ } else if (key === 'Subtract' || key === 'NumpadSubtract') {
22
+ key = '-'
23
+ } else {
24
+ // No action
25
+ }
26
+
27
+ // Check altKey first: AltGr (used on many non-US keyboards) reports both altKey
28
+ // and ctrlKey as true, and should still resolve to the Alt+ binding, not Ctrl+.
29
+ if (e.altKey) {
30
+ return `Alt+${key}`
31
+ }
32
+ if (e.ctrlKey) {
33
+ return `Ctrl+${key}`
34
+ }
35
+ return key
36
+ }
37
+
38
+ const resolveAction = (actions, type, key) => {
39
+ const actionName = keyboardMappings[type][key]
40
+ return actionName && actions[actionName] ? actionName : null
41
+ }
42
+
43
+ // keydown (pan/zoom) stays on the viewport so arrows only fire when the map has focus.
44
+ // keyup (Alt+K and other global shortcuts) attaches to the app container so it fires
45
+ // from anywhere within the app, including the features listbox.
46
+ const createKeyEventHandlers = (actions) => {
47
+ const handleKeyDown = (e) => {
48
+ const key = normalizeKey(e)
49
+ const actionName = resolveAction(actions, 'keydown', key)
50
+ if (actionName) {
51
+ actions[actionName](e)
52
+ e.preventDefault()
53
+ return
54
+ }
55
+ // A keyup-bound shortcut (e.g. Alt+Arrow label navigation) still acts on release,
56
+ // but its browser default (e.g. arrow-key scroll) must be suppressed now or it's too late.
57
+ if (resolveAction(actions, 'keyup', key)) {
58
+ e.preventDefault()
59
+ }
60
+ }
61
+
62
+ const handleKeyUp = (e) => {
63
+ const actionName = resolveAction(actions, 'keyup', normalizeKey(e))
64
+ if (actionName) {
65
+ actions[actionName](e)
66
+ e.preventDefault()
67
+ }
68
+ }
69
+
70
+ return { handleKeyDown, handleKeyUp }
71
+ }
72
+
8
73
  export function useKeyboardShortcuts (containerRef) {
9
74
  const { mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta, readMapText } = useConfig()
10
75
  const { dispatch, layoutRefs } = useApp()
@@ -27,42 +92,8 @@ export function useKeyboardShortcuts (containerRef) {
27
92
  readMapText
28
93
  })
29
94
 
30
- const normalizeKey = (e) => {
31
- let key
32
-
33
- // Use e.code for letters to avoid 'dead' keys with Alt/AltGr
34
- if (/^Key[A-Z]$/.test(e.code)) {
35
- key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I"
36
- } else {
37
- key = e.key // works for arrows, numpad, punctuation
38
- }
39
-
40
- // Normalize numpad add/subtract to symbols
41
- if (key === 'Add' || key === 'NumpadAdd') {
42
- key = '+'
43
- } else if (key === 'Subtract' || key === 'NumpadSubtract') {
44
- key = '-'
45
- } else {
46
- // No action
47
- }
48
-
49
- return e.altKey ? `Alt+${key}` : key
50
- }
51
-
52
- const handle = (type) => (e) => {
53
- const actionName = keyboardMappings[type][normalizeKey(e)]
54
- if (actionName && actions[actionName]) {
55
- actions[actionName](e)
56
- e.preventDefault()
57
- }
58
- }
59
-
60
- const handleKeyDown = handle('keydown')
61
- const handleKeyUp = handle('keyup')
95
+ const { handleKeyDown, handleKeyUp } = createKeyEventHandlers(actions)
62
96
 
63
- // keydown (pan/zoom) stays on the viewport so arrows only fire when the map has focus.
64
- // keyup (Alt+K and other global shortcuts) attaches to the app container so it fires
65
- // from anywhere within the app, including the features listbox.
66
97
  el.addEventListener('keydown', handleKeyDown)
67
98
  appEl.addEventListener('keyup', handleKeyUp)
68
99
 
@@ -138,6 +138,58 @@ describe('useKeyboardShortcuts', () => {
138
138
  expect(actions.stopPan).toHaveBeenCalled()
139
139
  })
140
140
 
141
+ test('does nothing on keyup when the key has no mapped action', () => {
142
+ const { appContainerRef, actions } = setup({ keyup: { ArrowUp: 'stopPan' }, actions: { stopPan: jest.fn() } })
143
+ renderHook(() => useKeyboardShortcuts({ current: document.createElement('div') }))
144
+
145
+ const evt = new KeyboardEvent('keyup', { key: 'Z' })
146
+ const spy = jest.spyOn(evt, 'preventDefault')
147
+ appContainerRef.current.dispatchEvent(evt)
148
+
149
+ expect(spy).not.toHaveBeenCalled()
150
+ expect(actions.stopPan).not.toHaveBeenCalled()
151
+ })
152
+
153
+ test('suppresses the browser default on keydown for a keyup-bound shortcut, without firing the action early', () => {
154
+ const { containerRef, appContainerRef, actions } = setup({
155
+ keyup: { 'Alt+ArrowUp': 'highlightNextLabel' },
156
+ actions: { highlightNextLabel: jest.fn() }
157
+ })
158
+ renderHook(() => useKeyboardShortcuts(containerRef))
159
+
160
+ const keydownEvt = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true })
161
+ const keydownSpy = jest.spyOn(keydownEvt, 'preventDefault')
162
+ containerRef.current.dispatchEvent(keydownEvt)
163
+ expect(keydownSpy).toHaveBeenCalled()
164
+ expect(actions.highlightNextLabel).not.toHaveBeenCalled()
165
+
166
+ appContainerRef.current.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp', altKey: true }))
167
+ expect(actions.highlightNextLabel).toHaveBeenCalled()
168
+ })
169
+
170
+ test('normalizes Ctrl+key combinations', () => {
171
+ const { containerRef, actions } = setup({
172
+ keydown: { 'Ctrl+I': 'getInfo' },
173
+ actions: { getInfo: jest.fn() }
174
+ })
175
+ renderHook(() => useKeyboardShortcuts(containerRef))
176
+
177
+ containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyI', key: 'i', ctrlKey: true }))
178
+ expect(actions.getInfo).toHaveBeenCalled()
179
+ })
180
+
181
+ test('treats AltGr (altKey + ctrlKey both true) as Alt+, not Ctrl+', () => {
182
+ const { containerRef, actions } = setup({
183
+ keydown: { 'Alt+I': 'getInfo', 'Ctrl+I': 'zoomIn' },
184
+ actions: { getInfo: jest.fn(), zoomIn: jest.fn() }
185
+ })
186
+ renderHook(() => useKeyboardShortcuts(containerRef))
187
+
188
+ containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyI', key: 'i', altKey: true, ctrlKey: true }))
189
+ expect(actions.getInfo).toHaveBeenCalled()
190
+ expect(actions.zoomIn).not.toHaveBeenCalled()
191
+ })
192
+
141
193
  test('prevents default when action exists, does nothing otherwise', () => {
142
194
  const { containerRef } = setup({ keydown: { I: 'zoomIn', X: 'nonExistent' } })
143
195
  renderHook(() => useKeyboardShortcuts(containerRef))
@@ -1,4 +1,4 @@
1
- import { useEffect } from 'react'
1
+ import { useEffect, useLayoutEffect } from 'react'
2
2
  import { useResizeObserver } from './useResizeObserver.js'
3
3
  import { constrainKeyboardFocus } from '../../utils/constrainKeyboardFocus.js'
4
4
  import { toggleInertElements } from '../../utils/toggleInertElements.js'
@@ -62,6 +62,44 @@ const setSlotCSSVar = (slot, layoutRefs, primaryMargin) => {
62
62
  root.style.setProperty(MODAL_MAX_HEIGHT, `${mainRect.height - relTop - primaryMargin}px`)
63
63
  }
64
64
 
65
+ // Computes and applies --modal-inset/--modal-max-height for the panel's current slot. Shared by
66
+ // the resize observer (keeps position correct across later layout changes while open) and the
67
+ // open-triggered layout effect below (panels stay permanently mounted — see mapPanels.js — so
68
+ // opening one doesn't itself resize mainRef and wouldn't otherwise trigger a recalc).
69
+ const recalcModalPosition = ({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs }) => {
70
+ if (!isModal || !mainRef.current) {
71
+ return
72
+ }
73
+
74
+ const root = document.documentElement
75
+ const styles = getComputedStyle(root)
76
+ const dividerGap = Number.parseInt(styles.getPropertyValue('--divider-gap'), 10)
77
+ const primaryMargin = Number.parseInt(styles.getPropertyValue('--primary-gap'), 10)
78
+ const slot = panelRef.current.dataset.slot
79
+
80
+ // Button-adjacent panels: position next to the controlling button.
81
+ // Use slot name (not buttonContainerEl) as the gate — buttonContainerEl may be undefined
82
+ // when there is no triggeringElement (e.g. panel opened programmatically).
83
+ // Dynamically query via aria-controls to handle stale triggeringElement after breakpoint changes.
84
+ if (slot?.endsWith('-button')) {
85
+ const panelElId = panelRef.current?.id
86
+ const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
87
+ const effectiveContainer = currentButtonEl?.parentElement ??
88
+ (buttonContainerEl?.isConnected ? buttonContainerEl : null) ??
89
+ document.querySelector(`[data-button-slot="${slot}"]`)
90
+
91
+ if (!effectiveContainer) {
92
+ return
93
+ }
94
+
95
+ setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
96
+ return
97
+ }
98
+
99
+ // Slot-based panels: derive position from the slot container element
100
+ setSlotCSSVar(slot, layoutRefs, primaryMargin)
101
+ }
102
+
65
103
  const useModalKeyHandler = (panelRef, isModal, handleClose) => {
66
104
  useEffect(() => {
67
105
  if (!isModal) {
@@ -131,40 +169,16 @@ export function useModalPanelBehaviour ({
131
169
 
132
170
  useModalKeyHandler(panelRef, isModal, handleClose)
133
171
 
134
- // === Set --modal-inset and --modal-max-height, recalculate on mainRef resize === //
135
- useResizeObserver([mainRef], () => {
136
- if (!isModal || !mainRef.current) {
137
- return
138
- }
139
-
140
- const root = document.documentElement
141
- const styles = getComputedStyle(root)
142
- const dividerGap = Number.parseInt(styles.getPropertyValue('--divider-gap'), 10)
143
- const primaryMargin = Number.parseInt(styles.getPropertyValue('--primary-gap'), 10)
144
- const slot = panelRef.current.dataset.slot
145
-
146
- // Button-adjacent panels: position next to the controlling button.
147
- // Use slot name (not buttonContainerEl) as the gate — buttonContainerEl may be undefined
148
- // when there is no triggeringElement (e.g. panel opened programmatically).
149
- // Dynamically query via aria-controls to handle stale triggeringElement after breakpoint changes.
150
- if (slot?.endsWith('-button')) {
151
- const panelElId = panelRef.current?.id
152
- const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
153
- const effectiveContainer = currentButtonEl?.parentElement ??
154
- (buttonContainerEl?.isConnected ? buttonContainerEl : null) ??
155
- document.querySelector(`[data-button-slot="${slot}"]`)
156
-
157
- if (!effectiveContainer) {
158
- return
159
- }
160
-
161
- setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
162
- return
163
- }
172
+ // === Recalculate on mainRef resize (keeps position correct while already open) === //
173
+ useResizeObserver([mainRef], () => recalcModalPosition({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs }))
164
174
 
165
- // Slot-based panels: derive position from the slot container element
166
- setSlotCSSVar(slot, layoutRefs, primaryMargin)
167
- })
175
+ // === Recalculate the moment the panel opens === //
176
+ // The resize observer above won't fire on its own here: its size cache persists across
177
+ // opens/closes (the panel no longer mounts fresh each time), and opening a panel doesn't
178
+ // itself resize mainRef. This is what actually positions the panel at open time.
179
+ useLayoutEffect(() => {
180
+ recalcModalPosition({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs })
181
+ }, [isModal, mainRef, panelRef, buttonContainerEl, layoutRefs])
168
182
 
169
183
  // === Click on modal backdrop to close === //
170
184
  useEffect(() => {
@@ -218,6 +218,65 @@ describe('useModalPanelBehaviour', () => {
218
218
  })
219
219
  })
220
220
 
221
+ // Regression: panels are mounted permanently and only toggle `hidden` (see mapPanels.js), so
222
+ // opening one doesn't itself resize mainRef. This uses the *real* useResizeObserver (backed by
223
+ // a fake ResizeObserver, not the blanket `(_, cb) => cb()` mock used above) so its size-change
224
+ // dedupe is actually exercised — that dedupe is what silently swallowed the reposition on open.
225
+ describe('repositioning on open when mainRef never resizes', () => {
226
+ const buttonSlot = 'map-styles-button'
227
+
228
+ beforeEach(() => {
229
+ const { useResizeObserver: actualUseResizeObserver } = jest.requireActual('./useResizeObserver.js')
230
+ useResizeObserverModule.useResizeObserver.mockImplementation(actualUseResizeObserver)
231
+
232
+ // Real ResizeObserver always delivers once per observe() call — mirror that, and let the
233
+ // real hook's own prevSizes dedupe decide whether the wrapped callback actually runs.
234
+ global.ResizeObserver = jest.fn(function (cb) {
235
+ this.observe = (target) => {
236
+ const entries = [{ target, contentRect: { width: 100, height: 50 } }]
237
+ cb(entries)
238
+ }
239
+ this.disconnect = jest.fn()
240
+ })
241
+ jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 0 })
242
+ jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
243
+ jest.spyOn(globalThis, 'getComputedStyle').mockReturnValue({ getPropertyValue: () => '8' })
244
+
245
+ Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
246
+ value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 }),
247
+ configurable: true
248
+ })
249
+ Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
250
+ value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 }),
251
+ configurable: true
252
+ })
253
+
254
+ refs.panel.current.dataset.slot = buttonSlot
255
+ const button = document.createElement('button')
256
+ button.setAttribute(ARIA_CONTROLS, PANEL_ID)
257
+ elements.buttonContainer.appendChild(button)
258
+ document.body.appendChild(elements.buttonContainer)
259
+ })
260
+
261
+ afterEach(() => {
262
+ jest.restoreAllMocks()
263
+ })
264
+
265
+ it('positions the panel the moment it opens, not just when mainRef resizes', () => {
266
+ // Mounted closed, as it would be permanently — mainRef's size gets recorded but the
267
+ // calc no-ops on the isModal guard.
268
+ const { rerender } = render(<TestComponent isModal={false} />)
269
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('')
270
+
271
+ // Open it. mainRef hasn't resized, so the resize observer's own delivery is deduped
272
+ // away — only the open-triggered layout effect can position it here.
273
+ rerender(<TestComponent isModal />)
274
+
275
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
276
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
277
+ })
278
+ })
279
+
221
280
  describe('focus management', () => {
222
281
  it('redirects focus into panel when focus enters app but outside panel', () => {
223
282
  refs.panel.current.focus = jest.fn()
@@ -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
+ }