@defra/interactive-map 0.0.43-fmp-experimental → 0.0.45-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 (297) hide show
  1. package/assets/templates/add-polygons.njk +11 -7
  2. package/assets/templates/add-symbols.njk +5 -1
  3. package/assets/templates/select-feature.njk +72 -15
  4. package/dist/css/index.css +1 -1
  5. package/dist/esm/im-core.js +1 -1
  6. package/dist/esm/im-shell.js +1 -1
  7. package/dist/umd/im-core.js +1 -1
  8. package/dist/umd/index.js +1 -1
  9. package/docs/api/context.md +24 -0
  10. package/docs/api/keyboard-shortcut-definition.md +102 -0
  11. package/docs/api.md +18 -8
  12. package/docs/building-a-plugin.md +1 -0
  13. package/docs/examples/add-marker-with-panel.mdx +4 -0
  14. package/docs/examples/add-polygons.mdx +16 -12
  15. package/docs/examples/add-symbols.mdx +4 -0
  16. package/docs/examples/basic-map.mdx +4 -0
  17. package/docs/examples/button-map.mdx +4 -0
  18. package/docs/examples/draw-tools.mdx +4 -0
  19. package/docs/examples/place-marker.mdx +4 -0
  20. package/docs/examples/search-control.mdx +4 -0
  21. package/docs/examples/select-feature.mdx +4 -0
  22. package/docs/examples/style-switcher.mdx +36 -44
  23. package/docs/examples/toggle-marker-label.mdx +4 -0
  24. package/docs/plugins/draw.md +2 -2
  25. package/docs/plugins/plugin-manifest.md +9 -0
  26. package/package.json +4 -2
  27. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  28. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -0
  29. package/plugins/beta/draw-es/src/events.js +17 -8
  30. package/plugins/beta/draw-es/src/reducer.js +8 -1
  31. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  32. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  33. package/plugins/beta/draw-ol/dist/css/index.css +1 -13
  34. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  35. package/plugins/beta/frame/dist/css/index.css +1 -11
  36. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  37. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  38. package/plugins/beta/frame/src/Frame.jsx +28 -2
  39. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  40. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  41. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  42. package/plugins/beta/scale-bar/dist/css/index.css +1 -8
  43. package/plugins/datasets/dist/css/5648.index.css +1 -5
  44. package/plugins/datasets/dist/css/index.css +1 -1
  45. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  46. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  47. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  48. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  49. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  50. package/plugins/datasets/src/registry/dataset.js +8 -12
  51. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  52. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  53. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  54. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  55. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  56. package/plugins/draw/dist/esm/index.js +1 -1
  57. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  58. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  59. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  60. package/plugins/draw/dist/umd/index.js +1 -1
  61. package/plugins/draw/src/DrawInit.jsx +41 -26
  62. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  63. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  64. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  65. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  66. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  67. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  68. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  69. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  70. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +23 -2
  71. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +53 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  73. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  74. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  75. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  76. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  77. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  78. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  79. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  80. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  86. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  87. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  88. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  89. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  90. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  91. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  92. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  93. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  94. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  95. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  96. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  97. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  98. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  99. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  100. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  101. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  102. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +40 -17
  103. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  104. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  105. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  106. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  107. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  108. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  109. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  110. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  111. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  112. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +40 -54
  113. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +32 -13
  114. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  115. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  116. package/plugins/draw/src/events.js +4 -4
  117. package/plugins/draw/src/events.test.js +1 -1
  118. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  119. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  120. package/plugins/draw/src/manifest.js +4 -1
  121. package/plugins/draw/src/manifest.test.js +19 -7
  122. package/plugins/draw/src/utils/spatial.js +1 -31
  123. package/plugins/draw/src/utils/spatial.test.js +1 -24
  124. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  125. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  126. package/plugins/interact/dist/umd/index.js +1 -1
  127. package/plugins/interact/src/InteractInit.jsx +2 -2
  128. package/plugins/interact/src/InteractInit.test.js +3 -3
  129. package/plugins/interact/src/events.js +11 -2
  130. package/plugins/interact/src/events.test.js +32 -0
  131. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  132. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  133. package/plugins/interact/src/hooks/useCrossHairVisibility.js +9 -4
  134. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  135. package/plugins/interact/src/hooks/useSpatialList.js +251 -0
  136. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +216 -70
  137. package/plugins/interact/src/manifest.js +12 -1
  138. package/plugins/interact/src/manifest.test.js +20 -0
  139. package/plugins/interact/src/utils/spatial.js +24 -1
  140. package/plugins/interact/src/utils/spatial.test.js +26 -1
  141. package/plugins/map-key/dist/css/index.css +1 -106
  142. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  143. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  144. package/plugins/map-key/dist/umd/index.js +1 -1
  145. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  146. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  147. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  148. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  149. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  150. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  151. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  152. package/plugins/map-key/src/manifest.js +1 -1
  153. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  154. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  155. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  156. package/plugins/menu/dist/css/index.css +1 -80
  157. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  158. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  159. package/plugins/menu/dist/umd/index.js +1 -1
  160. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  161. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  162. package/plugins/menu/src/initialise/createMenu.js +3 -1
  163. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  164. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  165. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  166. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  167. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  168. package/plugins/menu/src/reducers/pluginState.js +1 -6
  169. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  170. package/plugins/search/dist/css/index.css +1 -1
  171. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  172. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  173. package/plugins/search/src/components/Form/Form.jsx +3 -3
  174. package/plugins/search/src/components/Suggestions/Suggestions.jsx +2 -2
  175. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +1 -1
  176. package/plugins/search/src/manifest.js +5 -9
  177. package/plugins/search/src/manifest.test.js +5 -0
  178. package/providers/beta/esri/dist/css/index.css +1 -34
  179. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  180. package/providers/beta/esri/src/utils/spatial.js +15 -13
  181. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  182. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  183. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  184. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  185. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  186. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  187. package/providers/beta/openlayers/src/utils/spatial.js +16 -14
  188. package/providers/beta/openlayers/src/utils/spatial.test.js +10 -6
  189. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  190. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  191. package/providers/maplibre/dist/umd/index.js +1 -1
  192. package/providers/maplibre/src/defaults.test.js +26 -0
  193. package/providers/maplibre/src/maplibreProvider.js +1 -1
  194. package/providers/maplibre/src/utils/labels.js +2 -2
  195. package/providers/maplibre/src/utils/labels.test.js +5 -5
  196. package/providers/maplibre/src/utils/spatial.js +19 -74
  197. package/providers/maplibre/src/utils/spatial.test.js +8 -32
  198. package/rollup.esm.mjs +2 -1
  199. package/scripts/publish-package.sh +36 -0
  200. package/src/App/App.jsx +1 -1
  201. package/src/App/App.test.jsx +6 -1
  202. package/src/App/components/CrossHair/CrossHair.jsx +25 -11
  203. package/src/App/components/CrossHair/CrossHair.module.scss +37 -12
  204. package/src/App/components/CrossHair/CrossHair.test.jsx +40 -11
  205. package/src/App/components/Hints/Hints.jsx +3 -3
  206. package/src/App/components/Hints/Hints.test.jsx +3 -3
  207. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  208. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  209. package/src/App/components/MapButton/MapButton.jsx +19 -7
  210. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  211. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  212. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  213. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  214. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  215. package/src/App/components/Markers/Markers.jsx +2 -2
  216. package/src/App/components/Markers/Markers.test.jsx +3 -3
  217. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  218. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  219. package/src/App/components/Panel/Panel.jsx +19 -20
  220. package/src/App/components/Panel/Panel.module.scss +9 -0
  221. package/src/App/components/Panel/Panel.test.jsx +69 -4
  222. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  223. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  224. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  225. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  226. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  227. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +6 -6
  228. package/src/App/components/Viewport/SpatialList.test.jsx +285 -0
  229. package/src/App/components/Viewport/Viewport.jsx +21 -11
  230. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  231. package/src/App/controls/keyboardMappings.js +7 -2
  232. package/src/App/controls/keyboardMappings.test.js +21 -0
  233. package/src/App/controls/keyboardShortcuts.js +16 -2
  234. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  235. package/src/App/hooks/useCrossHairAPI.js +29 -1
  236. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  237. package/src/App/hooks/useHintsAPI.js +5 -5
  238. package/src/App/hooks/useInterfaceAPI.js +2 -1
  239. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  240. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  241. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  242. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  243. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  244. package/src/App/hooks/useSpatialListFocus.js +296 -0
  245. package/src/App/hooks/useSpatialListFocus.test.js +957 -0
  246. package/src/App/hooks/useSpatialListItems.js +51 -0
  247. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  248. package/src/App/initialiseApp.js +6 -3
  249. package/src/App/initialiseApp.test.js +11 -0
  250. package/src/App/layout/Layout.jsx +4 -2
  251. package/src/App/layout/Layout.test.jsx +21 -0
  252. package/src/App/layout/layout.module.scss +7 -1
  253. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  254. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  255. package/src/App/registry/pluginRegistry.js +13 -0
  256. package/src/App/registry/pluginRegistry.test.js +15 -0
  257. package/src/App/registry/spatialListRegistry.js +104 -0
  258. package/src/App/registry/spatialListRegistry.test.js +218 -0
  259. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  260. package/src/App/renderer/mapButtons.js +4 -0
  261. package/src/App/renderer/mapButtons.test.js +20 -0
  262. package/src/App/renderer/mapPanels.js +33 -53
  263. package/src/App/renderer/mapPanels.test.js +29 -4
  264. package/src/App/renderer/slotHelpers.js +33 -0
  265. package/src/App/renderer/slotHelpers.test.js +79 -2
  266. package/src/App/store/AppProvider.jsx +3 -2
  267. package/src/App/store/ServiceProvider.jsx +5 -3
  268. package/src/App/store/ServiceProvider.test.jsx +20 -1
  269. package/src/InteractiveMap/InteractiveMap.js +13 -2
  270. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  271. package/src/config/appConfig.js +17 -17
  272. package/src/config/appConfig.test.js +19 -19
  273. package/src/config/defaults.js +2 -2
  274. package/src/config/events.js +8 -8
  275. package/src/scss/main.scss +2 -2
  276. package/src/scss/tools/_govuk-content.scss +29 -0
  277. package/src/scss/tools/_index.scss +2 -1
  278. package/src/test-utils.js +8 -0
  279. package/src/types.js +53 -11
  280. package/src/utils/findNearestItemInDirection.js +29 -0
  281. package/src/utils/findNearestItemInDirection.test.js +67 -0
  282. package/src/utils/getPanelElementId.js +4 -0
  283. package/src/utils/getPanelElementId.test.js +11 -0
  284. package/src/utils/getPanelRole.js +22 -0
  285. package/src/utils/getPanelRole.test.js +56 -0
  286. package/src/utils/globalAltShortcuts.js +12 -0
  287. package/src/utils/globalAltShortcuts.test.js +27 -0
  288. package/src/utils/spatialNavigate.js +49 -0
  289. package/src/utils/spatialNavigate.test.js +42 -0
  290. package/webpack.umd.mjs +6 -1
  291. package/plugins/interact/src/hooks/useMapItemList.js +0 -187
  292. package/src/App/components/Viewport/Features.jsx +0 -35
  293. package/src/App/components/Viewport/Features.test.jsx +0 -124
  294. package/src/App/hooks/useFeatureFocus.js +0 -199
  295. package/src/App/hooks/useFeatureFocus.test.js +0 -635
  296. package/src/App/hooks/useFeatureItems.js +0 -39
  297. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -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'))
@@ -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()