@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
@@ -0,0 +1,285 @@
1
+ import React, { useRef } from 'react'
2
+ import { render, fireEvent, act } from '@testing-library/react'
3
+ import { SpatialList } from './SpatialList.jsx'
4
+ import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
5
+ import { useConfig } from '../../store/configContext.js'
6
+
7
+ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
8
+
9
+ const APP_ID = 'test-app'
10
+ const LISTBOX = '[role="listbox"]' // NOSONAR
11
+ const OPTION = '[role="option"]' // NOSONAR
12
+ const ARIA_SELECTED = 'aria-selected'
13
+ const ITEMS = [
14
+ { id: 'f1', label: 'Feature One' },
15
+ { id: 'f2', label: 'Feature Two' }
16
+ ]
17
+
18
+ beforeEach(() => {
19
+ useConfig.mockReturnValue({ id: APP_ID })
20
+ })
21
+
22
+ // ─── SpatialList — rendering ─────────────────────────────────────────────────────
23
+
24
+ describe('SpatialList — rendering', () => {
25
+ it('renders a listbox with the correct id', () => {
26
+ const { container } = render(<SpatialList />)
27
+ expect(container.querySelector(`#${APP_ID}-spatial-list`)).toBeTruthy()
28
+ expect(container.querySelector(LISTBOX)).toBeTruthy() // NOSONAR
29
+ })
30
+
31
+ it('renders no options when items is empty', () => {
32
+ const { container } = render(<SpatialList />)
33
+ expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) // NOSONAR
34
+ })
35
+
36
+ it('defaults aria-label to "Map features" when no label prop is given', () => {
37
+ const { container } = render(<SpatialList />)
38
+ expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Map features')
39
+ })
40
+
41
+ it('uses whatever label prop is given, e.g. for draw\'s edit-mode vertices', () => {
42
+ const { container } = render(<SpatialList label='Shape points' />)
43
+ expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Shape points')
44
+ })
45
+
46
+ it('renders one option per item with correct id, data-id and label', () => {
47
+ const { container } = render(<SpatialList items={ITEMS} />)
48
+ const options = container.querySelectorAll(OPTION)
49
+ expect(options).toHaveLength(2)
50
+ expect(options[0].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f1`)
51
+ expect(options[0].dataset.id).toBe('f1')
52
+ expect(options[0].textContent).toBe('Feature One')
53
+ expect(options[1].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f2`)
54
+ expect(options[1].dataset.id).toBe('f2')
55
+ expect(options[1].textContent).toBe('Feature Two')
56
+ })
57
+
58
+ it('positions an option at its x/y when provided, for coordinate-based AT overlays (e.g. Voice Control)', () => {
59
+ const itemsWithPosition = [{ id: 'f1', label: 'Feature One', x: 120, y: 340 }]
60
+ const { container } = render(<SpatialList items={itemsWithPosition} />)
61
+ const option = container.querySelector(OPTION)
62
+ expect(option.style.left).toBe('120px')
63
+ expect(option.style.top).toBe('340px')
64
+ })
65
+
66
+ it('does not set left/top when an item has no x/y', () => {
67
+ const { container } = render(<SpatialList items={ITEMS} />)
68
+ const option = container.querySelector(OPTION)
69
+ expect(option.style.left).toBe('')
70
+ expect(option.style.top).toBe('')
71
+ })
72
+
73
+ it('sets aria-selected on items present in selectedIds', () => {
74
+ const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1']} />)
75
+ const options = container.querySelectorAll(OPTION)
76
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
77
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
78
+ })
79
+
80
+ it('sets aria-selected on multiple items when selectedIds has multiple entries', () => {
81
+ const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1', 'f2']} />)
82
+ const options = container.querySelectorAll(OPTION)
83
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
84
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true')
85
+ })
86
+
87
+ it('does not set aria-selected from activeItemId alone', () => {
88
+ const { container } = render(<SpatialList items={ITEMS} activeItemId='f1' />)
89
+ const options = container.querySelectorAll(OPTION)
90
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
91
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
92
+ })
93
+
94
+ it('gives the active item tabIndex 0 and every other item tabIndex -1 (roving tabindex)', () => {
95
+ const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' />)
96
+ const options = container.querySelectorAll(OPTION)
97
+ expect(options[0].getAttribute('tabIndex')).toBe('-1')
98
+ expect(options[1].getAttribute('tabIndex')).toBe('0')
99
+ })
100
+
101
+ it('falls back to tabbableId for roving tabindex when activeItemId is absent', () => {
102
+ const { container } = render(<SpatialList items={ITEMS} tabbableId='f1' />)
103
+ const options = container.querySelectorAll(OPTION)
104
+ expect(options[0].getAttribute('tabIndex')).toBe('0')
105
+ expect(options[1].getAttribute('tabIndex')).toBe('-1')
106
+ })
107
+
108
+ it('gives every item tabIndex -1 when focusable is false, even the active one — never a real Tab stop', () => {
109
+ const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' focusable={false} />)
110
+ const options = container.querySelectorAll(OPTION)
111
+ expect(options[0].getAttribute('tabIndex')).toBe('-1')
112
+ expect(options[1].getAttribute('tabIndex')).toBe('-1')
113
+ })
114
+
115
+ it('sets aria-multiselectable when multiselectable is true', () => {
116
+ const { container } = render(<SpatialList items={ITEMS} multiselectable />)
117
+ expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBe('true') // NOSONAR
118
+ })
119
+
120
+ it('omits aria-multiselectable when multiselectable is false', () => {
121
+ const { container } = render(<SpatialList items={ITEMS} />)
122
+ expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBeNull() // NOSONAR
123
+ })
124
+
125
+ it('is not aria-hidden when items are present', () => {
126
+ const { container } = render(<SpatialList items={ITEMS} />)
127
+ const ul = container.querySelector(LISTBOX) // NOSONAR
128
+ expect(ul.getAttribute('aria-hidden')).toBeNull()
129
+ })
130
+
131
+ it('is aria-hidden and has no options when items is empty', () => {
132
+ const { container } = render(<SpatialList />)
133
+ const ul = container.querySelector(LISTBOX) // NOSONAR
134
+ expect(ul.getAttribute('aria-hidden')).toBe('true')
135
+ expect(container.querySelectorAll(OPTION)).toHaveLength(0) // NOSONAR
136
+ })
137
+
138
+ it('sets aria-describedby to the shared hints container id', () => {
139
+ const { container } = render(<SpatialList />)
140
+ expect(container.querySelector(LISTBOX).getAttribute('aria-describedby')).toBe(`${APP_ID}-keyboard-desc`) // NOSONAR
141
+ })
142
+ })
143
+
144
+ // ─── SpatialList — interactions ──────────────────────────────────────────────────
145
+
146
+ describe('SpatialList — interactions', () => {
147
+ it('calls onFocus when the listbox receives focus', () => {
148
+ const onFocus = jest.fn()
149
+ const { container } = render(<SpatialList onFocus={onFocus} />)
150
+ fireEvent.focus(container.querySelector(LISTBOX)) // NOSONAR
151
+ expect(onFocus).toHaveBeenCalled()
152
+ })
153
+
154
+ it('calls onBlur when the listbox loses focus', () => {
155
+ const onBlur = jest.fn()
156
+ const { container } = render(<SpatialList onBlur={onBlur} />)
157
+ fireEvent.blur(container.querySelector(LISTBOX)) // NOSONAR
158
+ expect(onBlur).toHaveBeenCalled()
159
+ })
160
+
161
+ it('calls onSelectItem with the clicked item id', () => {
162
+ const onSelectItem = jest.fn()
163
+ const { container } = render(<SpatialList items={ITEMS} onSelectItem={onSelectItem} />)
164
+ fireEvent.click(container.querySelectorAll(OPTION)[1]) // NOSONAR
165
+ expect(onSelectItem).toHaveBeenCalledWith('f2')
166
+ })
167
+
168
+ it('does not throw on click when onSelectItem is not provided', () => {
169
+ const { container } = render(<SpatialList items={ITEMS} />)
170
+ expect(() => fireEvent.click(container.querySelectorAll(OPTION)[0])).not.toThrow() // NOSONAR
171
+ })
172
+ })
173
+
174
+ // ─── SpatialList + useSpatialListFocus — roving tabindex wired together for real ────
175
+ //
176
+ // The tests above exercise SpatialList in isolation (mocked onFocus/onBlur), and
177
+ // useSpatialListFocus.test.js exercises the hook in isolation (onFocus/onBlur called
178
+ // directly, not via real DOM events). Neither can catch a bug where moving real focus
179
+ // between sibling options — which roving tabindex does on every arrow key — fires a
180
+ // genuine native focusin that bubbles up and re-triggers React's onFocus on the <ul>.
181
+ // Only rendering both together with real (unmocked) focus exercises that bubbling path.
182
+
183
+ const RovingHarness = ({ eventBus }) => {
184
+ const viewportRef = useRef(document.createElement('div'))
185
+ const spatialListRef = useRef(null)
186
+ const { activeItemId, tabbableId, selectedIds, onFocus, onBlur, selectItem } = useSpatialListFocus({
187
+ viewportRef, spatialListRef, items: ITEMS, eventBus, hints: { subscribe: () => () => {}, dismiss: () => {} }
188
+ })
189
+ return (
190
+ <SpatialList
191
+ ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds}
192
+ items={ITEMS} onFocus={onFocus} onBlur={onBlur} onSelectItem={selectItem}
193
+ />
194
+ )
195
+ }
196
+
197
+ describe('SpatialList + useSpatialListFocus — roving tabindex real focus events', () => {
198
+ it('emits map:setactivefeature exactly once when arrowing to the next option', () => {
199
+ const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
200
+ const { container } = render(<RovingHarness eventBus={eb} />)
201
+ act(() => container.querySelector('[data-id="f1"]').focus())
202
+ eb.emit.mockClear()
203
+ fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
204
+ expect(eb.emit).toHaveBeenCalledTimes(1)
205
+ expect(eb.emit).toHaveBeenCalledWith('map:setactiveitem', { id: 'f2' })
206
+ })
207
+
208
+ it('moves real DOM focus to the next option on ArrowDown', () => {
209
+ const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
210
+ const { container } = render(<RovingHarness eventBus={eb} />)
211
+ act(() => container.querySelector('[data-id="f1"]').focus())
212
+ fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
213
+ expect(document.activeElement.dataset.id).toBe('f2')
214
+ })
215
+
216
+ // A real pub/sub bus, with a minimal fake "interact plugin" listening on it — mirroring what
217
+ // useSpatialList.js actually does: track the last map:setactivefeature id, and on
218
+ // map:selectfeature, mark THAT id selected and fire interact:selectionchange back. This is
219
+ // the closest reproduction of the real reported bug: Enter only ever selecting the first item
220
+ // because every arrow move was clobbering the active id back to a stale value.
221
+ const makeFakeInteractBus = () => {
222
+ const listeners = {}
223
+ let lastActiveId = null
224
+ const bus = {
225
+ on: (event, fn) => { (listeners[event] ??= []).push(fn) },
226
+ off: (event, fn) => { listeners[event] = (listeners[event] ?? []).filter(f => f !== fn) },
227
+ emit: (event, payload) => { (listeners[event] ?? []).forEach(fn => fn(payload)) }
228
+ }
229
+ bus.on('map:setactiveitem', ({ id }) => { lastActiveId = id })
230
+ bus.on('map:selectitem', () => {
231
+ bus.emit('interact:selectionchange', { selectedMarkers: lastActiveId ? [lastActiveId] : [] })
232
+ })
233
+ return bus
234
+ }
235
+
236
+ it('selects the item that is actually focused, not the first item, after navigating and pressing Enter', () => {
237
+ const eb = makeFakeInteractBus()
238
+ const { container } = render(<RovingHarness eventBus={eb} />)
239
+ act(() => container.querySelector('[data-id="f1"]').focus())
240
+ const listbox = container.querySelector(LISTBOX)
241
+ fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // NOSONAR — moves to f2
242
+ fireEvent.keyDown(listbox, { key: 'Enter' })
243
+ const options = container.querySelectorAll(OPTION)
244
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false') // f1 — must not be the one selected
245
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true') // f2 — the one actually focused
246
+ })
247
+
248
+ it('keeps navigating correctly after a selection (does not get stuck reverting to the first item)', () => {
249
+ const eb = makeFakeInteractBus()
250
+ const { container } = render(<RovingHarness eventBus={eb} />)
251
+ act(() => container.querySelector('[data-id="f1"]').focus())
252
+ const listbox = container.querySelector(LISTBOX)
253
+ fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // NOSONAR — f1 -> f2
254
+ fireEvent.keyDown(listbox, { key: 'Enter' }) // select f2
255
+ fireEvent.keyDown(listbox, { key: 'ArrowUp' }) // f2 -> f1
256
+ expect(document.activeElement.dataset.id).toBe('f1')
257
+ expect(container.querySelectorAll(OPTION)[0].tabIndex).toBe(0)
258
+ })
259
+
260
+ it('selecting a marker on the map does not move the roving tabindex position — only aria-selected changes', () => {
261
+ const eb = makeFakeInteractBus()
262
+ const { container } = render(<RovingHarness eventBus={eb} />)
263
+ // Simulates repeatedly clicking a single marker on the map — never touches the list at all.
264
+ act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f1'] }) })
265
+ act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f2'] }) })
266
+ const options = container.querySelectorAll(OPTION)
267
+ expect(options[0].tabIndex).toBe(0) // f1 — the structural first item, unmoved
268
+ expect(options[1].tabIndex).toBe(-1)
269
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
270
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true') // only this changed
271
+ })
272
+
273
+ it('moves real focus to match the selected item when Tab lands somewhere else', () => {
274
+ const eb = makeFakeInteractBus()
275
+ const { container } = render(<RovingHarness eventBus={eb} />)
276
+ // f2 selected via a map click before any keyboard interaction — tabbableId stays on the
277
+ // structural first item (f1), so Tab lands there first.
278
+ act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f2'] }) })
279
+ expect(container.querySelectorAll(OPTION)[0].tabIndex).toBe(0) // Tab will land on f1
280
+ act(() => container.querySelector('[data-id="f1"]').focus()) // simulates the Tab press
281
+ // onFocus resolves to the selected item (f2) and must move real focus there to match —
282
+ // otherwise the list's own focus/announcement would disagree with what the map highlights.
283
+ expect(document.activeElement.dataset.id).toBe('f2')
284
+ })
285
+ })
@@ -1,7 +1,8 @@
1
1
  import React, { useRef, useEffect } from 'react'
2
- import { useFeatureFocus } from '../../hooks/useFeatureFocus.js'
3
- import { useFeatureItems } from '../../hooks/useFeatureItems.js'
2
+ import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
3
+ import { useSpatialListItems } from '../../hooks/useSpatialListItems.js'
4
4
  import { EVENTS as events } from '../../../config/events.js'
5
+ import { scaleFactor } from '../../../config/appConfig.js'
5
6
  import { useConfig } from '../../store/configContext.js'
6
7
  import { useApp } from '../../store/appContext.js'
7
8
  import { useMap } from '../../store/mapContext.js'
@@ -12,7 +13,7 @@ import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts.js'
12
13
  import { useMapEvents } from '../../hooks/useMapEvents.js'
13
14
  import { MapStatus } from './MapStatus.jsx'
14
15
  import { CrossHair } from '../CrossHair/CrossHair'
15
- import { Features } from './Features'
16
+ import { SpatialList } from './SpatialList'
16
17
  import { Markers } from '../Markers/Markers'
17
18
 
18
19
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
@@ -20,14 +21,23 @@ import { Markers } from '../Markers/Markers'
20
21
  export const Viewport = () => {
21
22
  const { id, mapProvider, mapLabel, keyboardHintText, focusOnMount } = useConfig()
22
23
  const { mode, previousMode, layoutRefs, safeZoneInset, dispatch } = useApp()
23
- const { mapSize } = useMap()
24
+ const { mapSize, isMapReady } = useMap()
24
25
  const { eventBus, hints } = useService()
25
26
 
26
27
  const mapContainerRef = useRef(null)
27
- const featuresRef = useRef(null)
28
+ const spatialListRef = useRef(null)
28
29
 
29
- const { items: featureItems, multiselectable } = useFeatureItems(eventBus)
30
- const { activeFeatureId, selectedIds, onFocus: handleFeaturesFocus, onBlur: handleFeaturesBlur } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems, eventBus, hints })
30
+ const { items: spatialListItems, multiselectable, label: spatialListLabel, focusable: spatialListFocusable } = useSpatialListItems(eventBus)
31
+ // Same reference point as Alt+Enter's highlightLabelAtCenter lets the listbox's entry
32
+ // fallback prefer whichever item is nearest the middle of the view. Guarded on isMapReady:
33
+ // the map engine doesn't exist yet on Viewport's first render, so mapToScreen()/getCenter()
34
+ // would throw; null is a safe, already-handled value (falls back to the first item).
35
+ let centerScreenPoint = null
36
+ if (isMapReady) {
37
+ const centerPixel = mapProvider.mapToScreen(mapProvider.getCenter())
38
+ centerScreenPoint = { x: centerPixel.x * scaleFactor[mapSize], y: centerPixel.y * scaleFactor[mapSize] }
39
+ }
40
+ const { activeItemId, tabbableId, selectedIds, onFocus: handleSpatialListFocus, onBlur: handleSpatialListBlur, selectItem } = useSpatialListFocus({ viewportRef: layoutRefs.viewportRef, spatialListRef, items: spatialListItems, eventBus, hints, centerScreenPoint })
31
41
 
32
42
  useEffect(() => {
33
43
  const handler = () => dispatch({ type: 'SET_LISTBOX_ACTIVE' })
@@ -35,8 +45,8 @@ export const Viewport = () => {
35
45
  return () => eventBus.off('interact:listboxcapable', handler)
36
46
  }, [eventBus])
37
47
 
38
- const onFeaturesFocus = () => { handleFeaturesFocus(); hints.show(keyboardHintText, { duration: 0 }) }
39
- const onFeaturesBlur = () => { handleFeaturesBlur(); hints.dismiss() }
48
+ const onSpatialListFocus = () => { handleSpatialListFocus(); hints.show(keyboardHintText, { duration: 0 }) }
49
+ const onSpatialListBlur = () => { handleSpatialListBlur(); hints.dismiss() }
40
50
 
41
51
  useKeyboardShortcuts(layoutRefs.viewportRef)
42
52
 
@@ -87,14 +97,14 @@ export const Viewport = () => {
87
97
  >
88
98
  <div className='im-c-viewport__map-container' ref={mapContainerRef} aria-hidden='true' />
89
99
  <MapStatus />
90
- <div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef} aria-hidden='true'>
100
+ <div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef}>
91
101
  <CrossHair />
92
102
  </div>
93
103
  <div className='im-c-viewport__markers' aria-hidden='true'>
94
104
  <Markers />
95
105
  </div>
96
106
  </div>
97
- <Features ref={featuresRef} activeFeatureId={activeFeatureId} selectedIds={selectedIds} multiselectable={multiselectable} items={featureItems} onFocus={onFeaturesFocus} onBlur={onFeaturesBlur} />
107
+ <SpatialList ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds} multiselectable={multiselectable} items={spatialListItems} label={spatialListLabel} focusable={spatialListFocusable} onFocus={onSpatialListFocus} onBlur={onSpatialListBlur} onSelectItem={selectItem} />
98
108
  </>
99
109
  )
100
110
  }
@@ -21,7 +21,13 @@ jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid
21
21
 
22
22
  const KEYBOARD_HINT_TEXT = 'Test keyboad hint text'
23
23
 
24
- const mockMapProvider = { initMap: jest.fn(), updateMap: jest.fn(), clearHighlightedLabel: jest.fn() }
24
+ const mockMapProvider = {
25
+ initMap: jest.fn(),
26
+ updateMap: jest.fn(),
27
+ clearHighlightedLabel: jest.fn(),
28
+ getCenter: jest.fn(() => [0, 0]),
29
+ mapToScreen: jest.fn(() => ({ x: 100, y: 100 }))
30
+ }
25
31
 
26
32
  function setupHookMocks (mainEl, viewportEl) {
27
33
  useConfig.mockReturnValue({
@@ -38,7 +44,7 @@ function setupHookMocks (mainEl, viewportEl) {
38
44
  safeZoneInset: {},
39
45
  dispatch: jest.fn()
40
46
  })
41
- useMap.mockReturnValue({ mapSize: 'medium', dispatch: jest.fn() })
47
+ useMap.mockReturnValue({ mapSize: 'medium', isMapReady: true, dispatch: jest.fn() })
42
48
  useService.mockReturnValue({
43
49
  announce: jest.fn(),
44
50
  hints: { show: jest.fn(), dismiss: jest.fn(), subscribe: jest.fn(() => jest.fn()) },
@@ -89,6 +95,16 @@ describe('Viewport rendering', () => {
89
95
  expect(markers).toBeInTheDocument()
90
96
  })
91
97
 
98
+ // Regression: Viewport (and MapController, which kicks off map creation) render before the
99
+ // underlying map engine actually exists — mapProvider.getCenter()/mapToScreen() throw until
100
+ // then, so the spatial listbox's "nearest to center" entry point must not call them yet.
101
+ it('does not throw, and skips mapProvider.getCenter/mapToScreen, before the map is ready', () => {
102
+ useMap.mockReturnValue({ mapSize: 'medium', isMapReady: false, dispatch: jest.fn() })
103
+ expect(() => renderViewport()).not.toThrow()
104
+ expect(mockMapProvider.getCenter).not.toHaveBeenCalled()
105
+ expect(mockMapProvider.mapToScreen).not.toHaveBeenCalled()
106
+ })
107
+
92
108
  it('renders viewport with correct id and class based on mapSize', () => {
93
109
  const { viewport } = renderViewport()
94
110
  expect(viewport.id).toBe('test-map-viewport')
@@ -1,3 +1,8 @@
1
+ import { isMac } from '../../utils/isMac.js'
2
+
3
+ // getInfo uses Ctrl on Windows/Linux (Alt+letter is reserved for menu mnemonics there) and Alt/Option on Mac — keep in sync with keyboardShortcuts.js's `infoKeyHtml`.
4
+ const infoModifier = isMac() ? 'Alt' : 'Ctrl'
5
+
1
6
  export const keyboardMappings = {
2
7
  keydown: {
3
8
  ArrowUp: 'panUp',
@@ -17,8 +22,8 @@ export const keyboardMappings = {
17
22
  'Alt+ArrowUp': 'highlightNextLabel',
18
23
  'Alt+ArrowDown': 'highlightNextLabel',
19
24
  'Alt+Enter': 'highlightLabelAtCenter',
20
- 'Alt+i': 'getInfo',
21
- 'Alt+I': 'getInfo',
25
+ [`${infoModifier}+i`]: 'getInfo',
26
+ [`${infoModifier}+I`]: 'getInfo',
22
27
  Escape: 'clearSelection'
23
28
  }
24
29
  }
@@ -0,0 +1,21 @@
1
+ describe('keyboardMappings', () => {
2
+ beforeEach(() => jest.resetModules())
3
+
4
+ const load = () => require('./keyboardMappings.js').keyboardMappings
5
+
6
+ it('binds getInfo to Alt+i/Alt+I on Mac', () => {
7
+ Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true })
8
+ const { keyup } = load()
9
+ expect(keyup['Alt+i']).toBe('getInfo')
10
+ expect(keyup['Alt+I']).toBe('getInfo')
11
+ expect(keyup['Ctrl+i']).toBeUndefined()
12
+ })
13
+
14
+ it('binds getInfo to Ctrl+i/Ctrl+I on non-Mac', () => {
15
+ Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
16
+ const { keyup } = load()
17
+ expect(keyup['Ctrl+i']).toBe('getInfo')
18
+ expect(keyup['Ctrl+I']).toBe('getInfo')
19
+ expect(keyup['Alt+i']).toBeUndefined()
20
+ })
21
+ })
@@ -1,6 +1,9 @@
1
1
  // src/controls/keyboardShortcuts.js
2
- const isMac = /mac/i.test(navigator.userAgentData?.platform ?? navigator.platform)
3
- const altKeyHtml = isMac ? '<kbd>Option</kbd>' : '<kbd>Alt</kbd>'
2
+ import { isMac } from '../../utils/isMac.js'
3
+
4
+ const altKeyHtml = isMac() ? '<kbd>Option</kbd>' : '<kbd>Alt</kbd>'
5
+ // getInfo uses Ctrl on Windows/Linux instead of Alt, which is reserved there for menu mnemonics.
6
+ const infoKeyHtml = isMac() ? '<kbd>Option</kbd>' : '<kbd>Ctrl</kbd>'
4
7
 
5
8
  export const coreShortcuts = [
6
9
  {
@@ -10,6 +13,17 @@ export const coreShortcuts = [
10
13
  context: 'global',
11
14
  enabled: true
12
15
  },
16
+ {
17
+ id: 'getInfo',
18
+ title: 'Get current location and visible area',
19
+ command: `${infoKeyHtml} + <kbd>I</kbd>`,
20
+ enabled: true,
21
+ // No visual equivalent, so hide the row from sighted users but keep it discoverable via assistive tech.
22
+ visuallyHidden: true,
23
+ // Only needs getCenter/getZoom, which every map provider implements, so no adapter needs to declare it.
24
+ mapProviderAgnostic: true,
25
+ requiredConfig: ['reverseGeocodeProvider']
26
+ },
13
27
  {
14
28
  id: 'moveLarge',
15
29
  title: 'Move in large steps',
@@ -21,4 +21,25 @@ describe('keyboardShortcuts', () => {
21
21
  expect(s.command).toContain('<kbd>Alt</kbd>')
22
22
  })
23
23
  })
24
+
25
+ it('marks getInfo as visuallyHidden, map-provider-agnostic and requiring reverseGeocodeProvider', () => {
26
+ const shortcuts = load()
27
+ const getInfo = shortcuts.find(s => s.id === 'getInfo')
28
+ expect(getInfo).toMatchObject({
29
+ visuallyHidden: true,
30
+ mapProviderAgnostic: true,
31
+ requiredConfig: ['reverseGeocodeProvider']
32
+ })
33
+ })
34
+
35
+ it('uses Ctrl for getInfo on non-Mac and Option on Mac', () => {
36
+ Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true })
37
+ let getInfo = load().find(s => s.id === 'getInfo')
38
+ expect(getInfo.command).toContain('<kbd>Option</kbd>')
39
+
40
+ jest.resetModules()
41
+ Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
42
+ getInfo = load().find(s => s.id === 'getInfo')
43
+ expect(getInfo.command).toContain('<kbd>Ctrl</kbd>')
44
+ })
24
45
  })
@@ -32,7 +32,19 @@ const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition
32
32
  dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: true } })
33
33
  }
34
34
 
35
+ // Fully hidden — display:none, not just faded — rather than staying in the accessibility
36
+ // tree while invisible. An earlier version kept it ax-tree-present here so Voice Control
37
+ // could re-summon it by name, but that made "Show Names" put a "Target" label over
38
+ // nothing whenever it was genuinely hidden, which is more confusing than helpful. Voice
39
+ // Control's real route back is opening MapControls (a normal, always-visible, always-named
40
+ // button) — see isMapControlsOpen below — so the crosshair itself doesn't need to stay
41
+ // discoverable while it's not actually there. Blocked outright while MapControls is open:
42
+ // that panel is a deliberate, explicit "I can't drag/pinch this map" signal from the user,
43
+ // which should win over whatever mouse-vs-touch/keyboard inference would otherwise decide.
35
44
  crossHair.hide = () => {
45
+ if (crossHair.isMapControlsOpen) {
46
+ return
47
+ }
36
48
  el.style.display = 'none'
37
49
  dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
38
50
  }
@@ -55,7 +67,7 @@ const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition
55
67
 
56
68
  export const useCrossHair = () => {
57
69
  const { mapProvider } = useConfig()
58
- const { safeZoneInset } = useApp()
70
+ const { safeZoneInset, expandedButtons } = useApp()
59
71
  const { eventBus } = useService()
60
72
  const { crossHair, dispatch, mapSize } = useMap()
61
73
 
@@ -98,6 +110,22 @@ export const useCrossHair = () => {
98
110
  }
99
111
  }, [mapSize])
100
112
 
113
+ // A plain property on the shared crossHair object, not dispatched state — every owner
114
+ // (draw's per-adapter modes, interact's useCrossHairVisibility) already holds this same
115
+ // object, so mutating it here is all that's needed for hide()'s own guard above to see the
116
+ // current value live, however long ago it was last set. This alone never shows or hides
117
+ // anything by itself — it only ever blocks a hide() call already in flight — so it's safe to
118
+ // run unconditionally even when no plugin that cares about it is loaded at all.
119
+ //
120
+ // Deliberately NOT a useEffect: this component's own effects and interact's/draw's own
121
+ // updateCrossHair effects both react to the same expandedButtons change, and React gives no
122
+ // ordering guarantee between two different components' effects — if the consumer's effect
123
+ // (which decides to call hide()) ran before this one updated the flag, hide()'s guard would
124
+ // read the stale value and incorrectly block itself. Setting it directly in the render body
125
+ // (matches DrawInit.jsx's shouldShowCrosshairRef convention) guarantees it's current before
126
+ // ANY component's effects run, since React finishes rendering the whole tree first.
127
+ crossHair.isMapControlsOpen = expandedButtons?.has('mapControls')
128
+
101
129
  return {
102
130
  crossHair,
103
131
  crossHairRef
@@ -35,7 +35,7 @@ describe('useCrossHair', () => {
35
35
  eventBus.off = jest.fn()
36
36
 
37
37
  useConfig.mockReturnValue({ mapProvider: mockMapProvider })
38
- useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 } })
38
+ useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 }, expandedButtons: new Set() })
39
39
  useService.mockReturnValue({ eventBus: mockEventBus })
40
40
  useMap.mockReturnValue({
41
41
  crossHair: mockCrossHair,
@@ -98,6 +98,22 @@ describe('useCrossHair', () => {
98
98
  expect(mockElement.style.display).toBe('none')
99
99
  })
100
100
 
101
+ it('hide() is a no-op while isMapControlsOpen is set', () => {
102
+ setup()
103
+ act(() => mockCrossHair.show())
104
+ mockCrossHair.isMapControlsOpen = true
105
+
106
+ act(() => mockCrossHair.hide())
107
+ expect(mockElement.style.display).toBe('block')
108
+ expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
109
+ })
110
+
111
+ it('keeps crossHair.isMapControlsOpen synced with expandedButtons, set synchronously during render (not a useEffect) so it is never stale relative to another component\'s own effect reacting to the same change', () => {
112
+ useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 }, expandedButtons: new Set(['mapControls']) })
113
+ setup()
114
+ expect(mockCrossHair.isMapControlsOpen).toBe(true)
115
+ })
116
+
101
117
  it('setStyle updates state', () => {
102
118
  setup()
103
119
  act(() => mockCrossHair.setStyle('highlighted'))
@@ -32,9 +32,9 @@ export const useHintsAPI = () => {
32
32
  }, [eventBus, hints])
33
33
 
34
34
  // Escape dismisses the active hint when the keypress originates inside this
35
- // map instance. The viewport and features listbox already dismiss hints on
35
+ // map instance. The viewport and spatial listbox already dismiss hints on
36
36
  // Escape within their own narrower focus scope (useKeyboardHint.js /
37
- // useFeatureFocus.js) — this covers hints shown via the public showHint()
37
+ // useSpatialListFocus.js) — this covers hints shown via the public showHint()
38
38
  // API from anywhere else in the same map's UI (e.g. a plugin button).
39
39
  //
40
40
  // Listening on document (rather than the map's own container) and checking
@@ -51,12 +51,12 @@ export const useHintsAPI = () => {
51
51
  }, [hints])
52
52
 
53
53
  useEffect(() => {
54
- const handleKeyDown = (e) => {
55
- if (e.key !== 'Escape' || !isHintActiveRef.current) {
54
+ const handleKeyDown = (event) => {
55
+ if (event.key !== 'Escape' || !isHintActiveRef.current) {
56
56
  return
57
57
  }
58
58
  const container = layoutRefs.appContainerRef?.current
59
- if (container && !container.contains(e.target)) {
59
+ if (container && !container.contains(event.target)) {
60
60
  return
61
61
  }
62
62
  hints.dismiss()
@@ -58,7 +58,8 @@ export const useInterfaceAPI = () => {
58
58
  const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
59
59
  const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
60
60
  const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
61
- const handleShowPanel = ({ id, focus = true }) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus } })
61
+ const handleShowPanel = ({ id, focus = true, triggeringElement }) =>
62
+ dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus, props: triggeringElement ? { triggeringElement } : undefined } })
62
63
  const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
63
64
  const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
64
65
 
@@ -92,6 +92,15 @@ describe('useInterfaceAPI', () => {
92
92
  expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: false } })
93
93
  })
94
94
 
95
+ it('dispatches OPEN_PANEL with props.triggeringElement when one is given, so Panel can return focus to it on close', () => {
96
+ const triggeringElement = document.createElement('button')
97
+ renderHook(() => useInterfaceAPI())
98
+ act(() => mockEventBus.emit('app:showpanel', { id: 'panel1', triggeringElement }))
99
+ expect(mockDispatch).toHaveBeenCalledWith({
100
+ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: true, props: { triggeringElement } }
101
+ })
102
+ })
103
+
95
104
  it('dispatches CLOSE_PANEL on app:hidepanel', () => {
96
105
  renderHook(() => useInterfaceAPI())
97
106
  act(() => mockEventBus.emit('app:hidepanel', 'panel1'))