@defra/interactive-map 0.0.15-alpha → 0.0.17-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 (263) hide show
  1. package/assets/css/docusaurus.css +104 -0
  2. package/assets/images/favicon.svg +1 -0
  3. package/assets/images/hero.png +0 -0
  4. package/assets/images/slot-map.svg +264 -0
  5. package/dist/css/index.css +1 -1
  6. package/dist/esm/im-core.js +1 -1
  7. package/dist/esm/im-shell.js +1 -1
  8. package/dist/umd/im-core.js +1 -1
  9. package/dist/umd/index.js +1 -1
  10. package/docs/api/slots.md +90 -6
  11. package/docs/api.md +4 -4
  12. package/docs/architecture.md +3 -1
  13. package/docs/{demo.mdx → examples.mdx} +1 -1
  14. package/docs/getting-started.md +5 -4
  15. package/docs/index.mdx +42 -0
  16. package/docs/plugins/datasets.md +561 -0
  17. package/docs/plugins/interact.md +176 -55
  18. package/docs/plugins/map-styles.md +64 -7
  19. package/docs/plugins/search.md +207 -63
  20. package/docs/plugins.md +8 -16
  21. package/docusaurus.config.cjs +34 -34
  22. package/jest.setup.js +1 -1
  23. package/package.json +6 -5
  24. package/plugins/beta/datasets/dist/css/index.css +85 -15
  25. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  27. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  28. package/plugins/beta/datasets/src/DatasetsInit.jsx +24 -9
  29. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  30. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +113 -0
  31. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +69 -0
  32. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +338 -0
  33. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +48 -0
  34. package/plugins/beta/datasets/src/api/addDataset.js +3 -9
  35. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  36. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  37. package/plugins/beta/datasets/src/api/removeDataset.js +3 -45
  38. package/plugins/beta/datasets/src/api/setData.js +8 -0
  39. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  40. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  41. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  42. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  43. package/plugins/beta/datasets/src/datasets.js +33 -59
  44. package/plugins/beta/datasets/src/defaults.js +43 -9
  45. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +39 -30
  46. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  47. package/plugins/beta/datasets/src/manifest.js +27 -19
  48. package/plugins/beta/datasets/src/panels/Key.jsx +129 -49
  49. package/plugins/beta/datasets/src/panels/Key.module.scss +48 -9
  50. package/plugins/beta/datasets/src/panels/Layers.jsx +131 -29
  51. package/plugins/beta/datasets/src/panels/Layers.module.scss +50 -8
  52. package/plugins/beta/datasets/src/reducer.js +128 -9
  53. package/plugins/beta/datasets/src/styles/patterns.js +157 -0
  54. package/plugins/beta/datasets/src/utils/bbox.js +8 -6
  55. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  56. package/plugins/beta/datasets/src/utils/mergeSublayer.js +78 -0
  57. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  58. package/plugins/beta/draw-es/src/DrawInit.jsx +16 -16
  59. package/plugins/beta/draw-es/src/api/addFeature.js +3 -3
  60. package/plugins/beta/draw-es/src/api/deleteFeature.js +3 -3
  61. package/plugins/beta/draw-es/src/api/editFeature.js +3 -3
  62. package/plugins/beta/draw-es/src/api/newPolygon.js +3 -3
  63. package/plugins/beta/draw-es/src/events.js +52 -20
  64. package/plugins/beta/draw-es/src/events.test.js +301 -0
  65. package/plugins/beta/draw-es/src/graphic.js +1 -1
  66. package/plugins/beta/draw-es/src/manifest.js +4 -4
  67. package/plugins/beta/draw-es/src/reducer.js +1 -1
  68. package/plugins/beta/draw-es/src/sketchViewModel.js +1 -1
  69. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  70. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  71. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  72. package/plugins/beta/draw-ml/src/DrawInit.jsx +49 -52
  73. package/plugins/beta/draw-ml/src/api/deleteFeature.js +1 -1
  74. package/plugins/beta/draw-ml/src/api/editFeature.js +8 -5
  75. package/plugins/beta/draw-ml/src/api/newLine.js +0 -1
  76. package/plugins/beta/draw-ml/src/api/newPolygon.js +0 -1
  77. package/plugins/beta/draw-ml/src/api/split.js +4 -4
  78. package/plugins/beta/draw-ml/src/defaults.js +1 -1
  79. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  80. package/plugins/beta/draw-ml/src/events.js +8 -6
  81. package/plugins/beta/draw-ml/src/manifest.js +29 -29
  82. package/plugins/beta/draw-ml/src/mapboxDraw.js +1 -1
  83. package/plugins/beta/draw-ml/src/mapboxSnap.js +17 -18
  84. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +31 -31
  85. package/plugins/beta/draw-ml/src/modes/disabledMode.js +1 -1
  86. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +11 -11
  87. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +7 -7
  88. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +8 -8
  89. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +7 -7
  90. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +32 -24
  91. package/plugins/beta/draw-ml/src/reducer.js +1 -1
  92. package/plugins/beta/draw-ml/src/undoStack.js +4 -4
  93. package/plugins/beta/draw-ml/src/utils/snapHelpers.js +12 -12
  94. package/plugins/beta/draw-ml/src/utils/spatial.js +11 -11
  95. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  96. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  97. package/plugins/beta/frame/src/Frame.jsx +9 -9
  98. package/plugins/beta/frame/src/FrameInit.jsx +4 -4
  99. package/plugins/beta/frame/src/api/addFrame.js +1 -1
  100. package/plugins/beta/frame/src/api/editFeature.js +1 -1
  101. package/plugins/beta/frame/src/config.js +1 -1
  102. package/plugins/beta/frame/src/manifest.js +3 -3
  103. package/plugins/beta/frame/src/reducer.js +1 -1
  104. package/plugins/beta/frame/src/utils.js +1 -1
  105. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  106. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  107. package/plugins/beta/map-styles/src/MapStyles.jsx +18 -18
  108. package/plugins/beta/map-styles/src/manifest.js +1 -1
  109. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  110. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  111. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  112. package/plugins/beta/scale-bar/src/ScaleBar.jsx +5 -5
  113. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  114. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  115. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  116. package/plugins/beta/use-location/src/UseLocation.jsx +1 -1
  117. package/plugins/beta/use-location/src/defaults.js +1 -1
  118. package/plugins/beta/use-location/src/events.js +3 -3
  119. package/plugins/interact/dist/css/index.css +1 -1
  120. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  121. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  122. package/plugins/interact/src/InteractInit.jsx +1 -2
  123. package/plugins/interact/src/api/enable.js +8 -5
  124. package/plugins/interact/src/api/enable.test.js +2 -2
  125. package/plugins/interact/src/api/selectFeature.js +4 -4
  126. package/plugins/interact/src/api/unselectFeature.js +5 -5
  127. package/plugins/interact/src/defaults.js +0 -1
  128. package/plugins/interact/src/events.test.js +15 -15
  129. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  130. package/plugins/interact/src/hooks/useInteractionHandlers.js +2 -2
  131. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +5 -5
  132. package/plugins/interact/src/interact.scss +0 -7
  133. package/plugins/interact/src/manifest.js +15 -19
  134. package/plugins/interact/src/manifest.test.js +6 -5
  135. package/plugins/interact/src/reducer.js +3 -3
  136. package/plugins/interact/src/reducer.test.js +0 -1
  137. package/plugins/interact/src/utils/spatial.js +10 -10
  138. package/plugins/interact/src/utils/spatial.test.js +14 -14
  139. package/plugins/search/dist/css/index.css +1 -1
  140. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  141. package/plugins/search/dist/esm/index.js +1 -1
  142. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  143. package/plugins/search/dist/umd/index.js +1 -1
  144. package/plugins/search/src/Search.jsx +7 -6
  145. package/plugins/search/src/Search.test.jsx +23 -23
  146. package/plugins/search/src/components/CloseButton/CloseButton.jsx +15 -15
  147. package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +2 -2
  148. package/plugins/search/src/components/Form/Form.jsx +14 -14
  149. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  150. package/plugins/search/src/components/Form/Form.test.jsx +11 -11
  151. package/plugins/search/src/components/OpenButton/OpenButton.jsx +16 -15
  152. package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +6 -2
  153. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +15 -15
  154. package/plugins/search/src/components/Suggestions/Suggestions.jsx +6 -6
  155. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +4 -4
  156. package/plugins/search/src/datasets.js +12 -13
  157. package/plugins/search/src/datasets.test.js +1 -1
  158. package/plugins/search/src/defaults.js +1 -1
  159. package/plugins/search/src/events/fetchSuggestions.js +3 -3
  160. package/plugins/search/src/events/fetchSuggestions.test.js +1 -1
  161. package/plugins/search/src/events/formHandlers.js +3 -3
  162. package/plugins/search/src/events/formHandlers.test.js +1 -1
  163. package/plugins/search/src/events/index.js +2 -2
  164. package/plugins/search/src/events/index.test.js +2 -2
  165. package/plugins/search/src/events/inputHandlers.js +4 -4
  166. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  167. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  168. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  169. package/plugins/search/src/index.js +2 -1
  170. package/plugins/search/src/index.test.js +3 -3
  171. package/plugins/search/src/manifest.js +6 -4
  172. package/plugins/search/src/reducer.js +1 -2
  173. package/plugins/search/src/reducer.test.js +2 -2
  174. package/plugins/search/src/search.scss +10 -3
  175. package/plugins/search/src/utils/parseOsNamesResults.js +1 -2
  176. package/plugins/search/src/utils/parseOsNamesResults.test.js +2 -2
  177. package/plugins/search/src/utils/updateMap.js +1 -1
  178. package/plugins/search/src/utils/updateMap.test.js +5 -5
  179. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  180. package/providers/beta/esri/src/esriProvider.js +5 -5
  181. package/providers/beta/esri/src/utils/coords.js +1 -1
  182. package/providers/beta/esri/src/utils/esriFixes.js +1 -1
  183. package/providers/beta/esri/src/utils/query.js +4 -4
  184. package/providers/beta/esri/src/utils/spatial.js +1 -2
  185. package/providers/beta/esri/src/utils/spatial.test.js +4 -1
  186. package/providers/beta/open-names/src/utils/mapToLocationModel.test.js +1 -1
  187. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  188. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  189. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  190. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  191. package/providers/maplibre/src/appEvents.test.js +1 -1
  192. package/providers/maplibre/src/index.js +1 -1
  193. package/providers/maplibre/src/index.test.js +3 -5
  194. package/providers/maplibre/src/mapEvents.test.js +15 -5
  195. package/providers/maplibre/src/maplibreProvider.test.js +6 -2
  196. package/providers/maplibre/src/utils/calculateLinearTextSize.js +4 -4
  197. package/providers/maplibre/src/utils/calculateLinearTextSize.test.js +3 -3
  198. package/providers/maplibre/src/utils/detectWebgl.test.js +1 -1
  199. package/providers/maplibre/src/utils/highlightFeatures.js +3 -2
  200. package/providers/maplibre/src/utils/highlightFeatures.test.js +13 -6
  201. package/providers/maplibre/src/utils/labels.js +19 -20
  202. package/providers/maplibre/src/utils/labels.test.js +15 -13
  203. package/providers/maplibre/src/utils/maplibreFixes.test.js +1 -1
  204. package/providers/maplibre/src/utils/queryFeatures.js +6 -6
  205. package/providers/maplibre/src/utils/queryFeatures.test.js +13 -13
  206. package/providers/maplibre/src/utils/spatial.js +0 -1
  207. package/providers/maplibre/src/utils/spatial.test.js +26 -27
  208. package/src/App/components/Actions/Actions.jsx +2 -2
  209. package/src/App/components/Actions/Actions.module.scss +0 -7
  210. package/src/App/components/Actions/Actions.test.jsx +1 -1
  211. package/src/App/components/Icon/Icon.jsx +3 -2
  212. package/src/App/components/Icon/Icon.module.scss +4 -0
  213. package/src/App/components/Icon/Icon.test.jsx +43 -4
  214. package/src/App/components/MapButton/MapButton.jsx +42 -17
  215. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  216. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  217. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  218. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  219. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  220. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  221. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  222. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  223. package/src/App/hooks/useInterfaceAPI.js +6 -0
  224. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  225. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  226. package/src/App/layout/Layout.jsx +12 -7
  227. package/src/App/layout/Layout.test.jsx +2 -2
  228. package/src/App/layout/layout.module.scss +67 -29
  229. package/src/App/registry/pluginRegistry.js +17 -0
  230. package/src/App/registry/pluginRegistry.test.js +33 -0
  231. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  232. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  233. package/src/App/renderer/mapButtons.js +3 -2
  234. package/src/App/renderer/mapPanels.test.js +2 -2
  235. package/src/App/renderer/slotHelpers.js +2 -2
  236. package/src/App/renderer/slotHelpers.test.js +5 -5
  237. package/src/App/renderer/slots.js +9 -5
  238. package/src/App/store/AppProvider.jsx +3 -1
  239. package/src/App/store/AppProvider.test.jsx +1 -1
  240. package/src/App/store/ServiceProvider.jsx +3 -1
  241. package/src/App/store/appActionsMap.js +16 -0
  242. package/src/App/store/appActionsMap.test.js +27 -0
  243. package/src/App/store/appDispatchMiddleware.js +33 -1
  244. package/src/App/store/appDispatchMiddleware.test.js +250 -222
  245. package/src/App/store/appReducer.js +2 -0
  246. package/src/InteractiveMap/InteractiveMap.js +4 -0
  247. package/src/config/appConfig.js +7 -4
  248. package/src/config/events.js +28 -0
  249. package/src/scss/main.scss +1 -0
  250. package/src/scss/settings/_dimensions.scss +0 -1
  251. package/src/services/logger.js +6 -0
  252. package/src/services/logger.test.js +32 -0
  253. package/src/utils/getSafeZoneInset.js +9 -7
  254. package/src/utils/getSafeZoneInset.test.js +10 -10
  255. package/webpack.dev.mjs +23 -19
  256. package/docs/govuk-prototype.md +0 -23
  257. package/docs/index.md +0 -19
  258. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  259. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  260. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  261. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  262. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  263. package/plugins/beta/datasets/src/mapLayers.js +0 -165
@@ -5,14 +5,13 @@ import {
5
5
  navigateToNextLabel, createMapLabelNavigator
6
6
  } from './labels.js'
7
7
 
8
- jest.mock('./spatial.js', () => ({ spatialNavigate: jest.fn() }))
9
- jest.mock('./calculateLinearTextSize.js', () => ({ calculateLinearTextSize: jest.fn(() => 12) }))
10
-
11
8
  import { spatialNavigate } from './spatial.js'
12
9
  import { calculateLinearTextSize } from './calculateLinearTextSize.js'
13
10
 
14
- describe('labels utils', () => {
11
+ jest.mock('./spatial.js', () => ({ spatialNavigate: jest.fn() }))
12
+ jest.mock('./calculateLinearTextSize.js', () => ({ calculateLinearTextSize: jest.fn(() => 12) }))
15
13
 
14
+ describe('labels utils', () => {
16
15
  test('getGeometryCenter all geometry types', () => {
17
16
  expect(getGeometryCenter({ type: 'Point', coordinates: [1, 2] })).toEqual([1, 2])
18
17
  expect(getGeometryCenter({ type: 'MultiPoint', coordinates: [[3, 4]] })).toEqual([3, 4])
@@ -30,9 +29,9 @@ describe('labels utils', () => {
30
29
  expect(evalInterpolate(['literal', 'x'], 10)).toBe(12)
31
30
  expect(() => evalInterpolate(['interpolate', ['linear'], ['get', 'p'], 5, 10], 10)).toThrow()
32
31
  const expr = ['interpolate', ['linear'], ['zoom'], 5, 10, 10, 20]
33
- expect(evalInterpolate(expr, 3)).toBe(10) // zoom <= z0
34
- expect(evalInterpolate(expr, 7.5)).toBe(15) // interpolated
35
- expect(evalInterpolate(expr, 15)).toBe(20) // beyond last stop
32
+ expect(evalInterpolate(expr, 3)).toBe(10) // zoom <= z0
33
+ expect(evalInterpolate(expr, 7.5)).toBe(15) // interpolated
34
+ expect(evalInterpolate(expr, 15)).toBe(20) // beyond last stop
36
35
  })
37
36
 
38
37
  test('getHighlightColors', () => {
@@ -77,7 +76,7 @@ describe('labels utils', () => {
77
76
 
78
77
  test('findClosestLabel: empty → undefined; returns closest; skips farther', () => {
79
78
  expect(findClosestLabel([], { x: 0, y: 0 })).toBeUndefined()
80
- const labels = [{ x: 2, y: 0 }, { x: 10, y: 0 }] // closer first → second hits false branch
79
+ const labels = [{ x: 2, y: 0 }, { x: 10, y: 0 }] // closer first → second hits false branch
81
80
  expect(findClosestLabel(labels, { x: 0, y: 0 })).toBe(labels[0])
82
81
  })
83
82
 
@@ -109,9 +108,12 @@ describe('labels utils', () => {
109
108
 
110
109
  test('applyHighlight: early returns without feature.layer; applies otherwise', () => {
111
110
  const map = {
112
- getLayer: jest.fn(), removeLayer: jest.fn(),
111
+ getLayer: jest.fn(),
112
+ removeLayer: jest.fn(),
113
113
  getSource: jest.fn(() => ({ setData: jest.fn() })),
114
- getZoom: jest.fn(() => 10), addLayer: jest.fn(), moveLayer: jest.fn()
114
+ getZoom: jest.fn(() => 10),
115
+ addLayer: jest.fn(),
116
+ moveLayer: jest.fn()
115
117
  }
116
118
  const state = { highlightLayerId: null, highlightedExpr: null, isDarkStyle: false }
117
119
  applyHighlight(map, null, state)
@@ -132,9 +134,9 @@ describe('labels utils', () => {
132
134
  currentPixel: { x: 1, y: 1 }, labels: [{ x: 1, y: 1 }]
133
135
  })).toBeNull()
134
136
  const state = { currentPixel: { x: 0, y: 0 }, labels: [{ x: 0, y: 0 }, { x: 5, y: 5 }] }
135
- spatialNavigate.mockReturnValue(-1) // out of range → use 0
137
+ spatialNavigate.mockReturnValue(-1) // out of range → use 0
136
138
  expect(navigateToNextLabel('ArrowRight', state)).toBe(state.labels[1])
137
- spatialNavigate.mockReturnValue(0) // valid index
139
+ spatialNavigate.mockReturnValue(0) // valid index
138
140
  expect(navigateToNextLabel('ArrowRight', state)).toBe(state.labels[1])
139
141
  })
140
142
 
@@ -212,7 +214,7 @@ describe('labels utils', () => {
212
214
  })
213
215
 
214
216
  test('initLabelSource skips addSource when source exists; MAP_SET_STYLE triggers re-init', () => {
215
- map.getSource.mockReset().mockReturnValue({ setData: jest.fn() }) // source always exists
217
+ map.getSource.mockReset().mockReturnValue({ setData: jest.fn() }) // source always exists
216
218
  const eventBus = { on: jest.fn() }
217
219
  createMapLabelNavigator(map, 'light', { MAP_SET_STYLE: 'set-style' }, eventBus)
218
220
  expect(map.addSource).not.toHaveBeenCalled()
@@ -63,4 +63,4 @@ describe('applyPreventDefaultFix', () => {
63
63
  e.preventDefault()
64
64
  expect(spy).toHaveBeenCalled()
65
65
  })
66
- })
66
+ })
@@ -30,7 +30,7 @@ const isPointInPolygon = (point, ring) => {
30
30
  const intersectX = ((xj - xi) * (py - yi)) / (yj - yi) + xi
31
31
 
32
32
  if (px < intersectX) {
33
- inside = !inside;
33
+ inside = !inside
34
34
  }
35
35
  }
36
36
  return inside
@@ -43,7 +43,7 @@ const getMinDistToGeometry = (map, point, geometry) => {
43
43
  const { coordinates: coords, type } = geometry
44
44
  let minSqDist = Infinity
45
45
  const getScreenPt = (lngLat) => map.project(lngLat)
46
-
46
+
47
47
  const processLine = (lineCoords) => {
48
48
  for (let i = 0; i < lineCoords.length - 1; i++) {
49
49
  const d2 = distToSegmentSquared(point, getScreenPt(lineCoords[i]), getScreenPt(lineCoords[i + 1]))
@@ -52,7 +52,7 @@ const getMinDistToGeometry = (map, point, geometry) => {
52
52
  }
53
53
  }
54
54
  }
55
-
55
+
56
56
  if (type === 'Point') {
57
57
  const p = getScreenPt(coords)
58
58
  minSqDist = (point.x - p.x) ** 2 + (point.y - p.y) ** 2
@@ -117,7 +117,7 @@ export const queryFeatures = (map, point, options = {}) => {
117
117
  let score = 0
118
118
  const type = f.geometry.type
119
119
  const pixelDistSq = getMinDistToGeometry(map, point, f.geometry)
120
-
120
+
121
121
  // PRIORITY 1: LAYER ORDER
122
122
  const layerRank = layerStack.indexOf(f.layer.id)
123
123
  score += (layerRank * 1000000)
@@ -126,7 +126,7 @@ export const queryFeatures = (map, point, options = {}) => {
126
126
  if (type.includes('Polygon')) {
127
127
  const polys = type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
128
128
  const isInside = polys.some((ring) => isPointInPolygon(clickPt, ring[0]))
129
-
129
+
130
130
  if (isInside === true) {
131
131
  // Massive boost for polygons if we are actually inside them
132
132
  score -= 500000 // NOSONAR - tolerance used only here
@@ -143,4 +143,4 @@ export const queryFeatures = (map, point, options = {}) => {
143
143
  })
144
144
  .sort((a, b) => a.score - b.score)
145
145
  .map(({ f }) => f)
146
- }
146
+ }
@@ -15,12 +15,12 @@ describe('queryFeatures coverage', () => {
15
15
  const cases = [
16
16
  { type: 'Point', coords: [0, 0], p: { x: 3, y: 4 } },
17
17
  { type: 'LineString', coords: [[0, 0], [10, 0]], p: { x: 5, y: 5 } }, // t=0.5
18
- { type: 'LineString', coords: [[0, 0], [0, 0]], p: { x: 1, y: 1 } }, // l2=0
18
+ { type: 'LineString', coords: [[0, 0], [0, 0]], p: { x: 1, y: 1 } }, // l2=0
19
19
  { type: 'LineString', coords: [[0, 0], [10, 0]], p: { x: -5, y: 0 } }, // t<0
20
20
  { type: 'MultiPoint', coords: [[0, 0], [10, 10]], p: { x: 1, y: 0 } },
21
21
  { type: 'MultiLineString', coords: [[[0, 0], [10, 0]]], p: { x: 5, y: 1 } },
22
22
  { type: 'Polygon', coords: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], p: { x: 5, y: 5 } }, // Inside
23
- { type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x: 20, y: 20 } }, // Outside
23
+ { type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x: 20, y: 20 } }, // Outside
24
24
  { type: 'Unknown', coords: [], p: { x: 0, y: 0 } }
25
25
  ]
26
26
 
@@ -31,21 +31,21 @@ describe('queryFeatures coverage', () => {
31
31
  })
32
32
 
33
33
  // 3. Hits Line 144 (.sort) and property-based ID fallback
34
- const f1 = {
35
- properties: { key: 'a' },
36
- layer: { id: 'layer-A' },
37
- geometry: { type: 'Point', coordinates: [10, 10] }
34
+ const f1 = {
35
+ properties: { key: 'a' },
36
+ layer: { id: 'layer-A' },
37
+ geometry: { type: 'Point', coordinates: [10, 10] }
38
38
  }
39
- const f2 = {
40
- id: 'b',
41
- layer: { id: 'layer-B' },
42
- geometry: { type: 'Point', coordinates: [0, 0] }
39
+ const f2 = {
40
+ id: 'b',
41
+ layer: { id: 'layer-B' },
42
+ geometry: { type: 'Point', coordinates: [0, 0] }
43
43
  }
44
-
44
+
45
45
  // map.queryRenderedFeatures returns multiple items to trigger .sort()
46
46
  const sortMap = { ...mockMap, queryRenderedFeatures: () => [f1, f2] }
47
47
  const result = queryFeatures(sortMap, { x: 0, y: 0 })
48
-
48
+
49
49
  expect(result.length).toBe(2)
50
50
  expect(result[0].layer.id).toBe('layer-A') // Sorted by layerStack index
51
51
 
@@ -57,4 +57,4 @@ describe('queryFeatures coverage', () => {
57
57
  const rayMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
58
58
  expect(queryFeatures(rayMap, { x: -1, y: 5 }).length).toBe(1)
59
59
  })
60
- })
60
+ })
@@ -186,7 +186,6 @@ const getPaddedBounds = (LngLatBounds, map) => {
186
186
  return new LngLatBounds(swLngLat, neLngLat)
187
187
  }
188
188
 
189
-
190
189
  /**
191
190
  * Get a flat bbox [west, south, east, north] from any GeoJSON object
192
191
  * (Feature, FeatureCollection, or geometry).
@@ -10,7 +10,6 @@ jest.mock('geodesy/latlon-spherical.js', () =>
10
10
  jest.mock('@turf/bbox', () => jest.fn(() => [-1, 50, 1, 52]))
11
11
 
12
12
  describe('spatial utils', () => {
13
-
14
13
  test('formatDimension hits all branches', () => {
15
14
  // < 0.5 miles
16
15
  expect(spatial.formatDimension(500)).toMatch(/m$/)
@@ -43,55 +42,55 @@ describe('spatial utils', () => {
43
42
  })
44
43
 
45
44
  test('north/south/east/west moves', () => {
46
- expect(spatial.getCardinalMove([0,0],[0,0.5])).toMatch(/north/)
47
- expect(spatial.getCardinalMove([0,0],[0,-0.5])).toMatch(/south/)
48
- expect(spatial.getCardinalMove([0,0],[0.5,0])).toMatch(/east/)
49
- expect(spatial.getCardinalMove([0,0],[-0.5,0])).toMatch(/west/)
50
- expect(spatial.getCardinalMove([0,0],[0.5,0.5])).toMatch(/north.*east|east.*north/)
51
- expect(spatial.getCardinalMove([0,0],[0.00001,0.00001])).toBe('')
45
+ expect(spatial.getCardinalMove([0, 0], [0, 0.5])).toMatch(/north/)
46
+ expect(spatial.getCardinalMove([0, 0], [0, -0.5])).toMatch(/south/)
47
+ expect(spatial.getCardinalMove([0, 0], [0.5, 0])).toMatch(/east/)
48
+ expect(spatial.getCardinalMove([0, 0], [-0.5, 0])).toMatch(/west/)
49
+ expect(spatial.getCardinalMove([0, 0], [0.5, 0.5])).toMatch(/north.*east|east.*north/)
50
+ expect(spatial.getCardinalMove([0, 0], [0.00001, 0.00001])).toBe('')
52
51
  })
53
52
 
54
53
  test('spatialNavigate all directions and fallback', () => {
55
- const pixels = [[0,0],[0,-1],[1,0],[0,1],[-1,0]]
56
- expect(spatial.spatialNavigate('ArrowUp',[0,0],pixels)).toBe(1)
57
- expect(spatial.spatialNavigate('ArrowDown',[0,0],pixels)).toBe(3)
58
- expect(spatial.spatialNavigate('ArrowLeft',[0,0],pixels)).toBe(4)
59
- expect(spatial.spatialNavigate('ArrowRight',[0,0],pixels)).toBe(2)
60
- expect(spatial.spatialNavigate('InvalidDir',[0,0],pixels)).toBe(0)
54
+ const pixels = [[0, 0], [0, -1], [1, 0], [0, 1], [-1, 0]]
55
+ expect(spatial.spatialNavigate('ArrowUp', [0, 0], pixels)).toBe(1)
56
+ expect(spatial.spatialNavigate('ArrowDown', [0, 0], pixels)).toBe(3)
57
+ expect(spatial.spatialNavigate('ArrowLeft', [0, 0], pixels)).toBe(4)
58
+ expect(spatial.spatialNavigate('ArrowRight', [0, 0], pixels)).toBe(2)
59
+ expect(spatial.spatialNavigate('InvalidDir', [0, 0], pixels)).toBe(0)
61
60
  })
62
61
 
63
62
  test('spatialNavigate finds closer candidates (hits dist < minDist)', () => {
64
- const start = [0,0]
65
- const pixels = [[0,0],[10,0],[2,0]]
63
+ const start = [0, 0]
64
+ const pixels = [[0, 0], [10, 0], [2, 0]]
66
65
  expect(spatial.spatialNavigate('ArrowRight', start, pixels)).toBe(2)
67
66
  })
68
67
 
69
68
  test('spatialNavigate skips farther candidate (dist >= minDist false branch)', () => {
70
69
  // Closer candidate first → second candidate fails dist < minDist
71
- const pixels = [[0,0],[2,0],[10,0]]
72
- expect(spatial.spatialNavigate('ArrowRight', [0,0], pixels)).toBe(1)
70
+ const pixels = [[0, 0], [2, 0], [10, 0]]
71
+ expect(spatial.spatialNavigate('ArrowRight', [0, 0], pixels)).toBe(1)
73
72
  })
74
73
 
75
74
  test('spatialNavigate diagonal with dx>dy', () => {
76
- const start = [0,0]
77
- const pixels = [[0,0],[3,1],[1,0]] // dx>dy
75
+ const start = [0, 0]
76
+ const pixels = [[0, 0], [3, 1], [1, 0]] // dx>dy
78
77
  expect(spatial.spatialNavigate('ArrowRight', start, pixels)).toBe(2)
79
78
  })
80
79
 
81
80
  test('getResolution returns positive value', () => {
82
- expect(spatial.getResolution({lat:0},1)).toBeGreaterThan(0)
81
+ expect(spatial.getResolution({ lat: 0 }, 1)).toBeGreaterThan(0)
83
82
  })
84
83
 
85
84
  test('getPaddedBounds returns bounds', () => {
86
85
  const map = {
87
- getContainer: () => ({ getBoundingClientRect: () => ({ width:100,height:200 }) }),
88
- getPadding: () => ({ top:1,right:2,bottom:3,left:4 }),
89
- unproject: p => ({ x:p[0], y:p[1] })
86
+ getContainer: () => ({ getBoundingClientRect: () => ({ width: 100, height: 200 }) }),
87
+ getPadding: () => ({ top: 1, right: 2, bottom: 3, left: 4 }),
88
+ unproject: p => ({ x: p[0], y: p[1] })
90
89
  }
91
- const LngLatBounds = function(sw,ne){
92
- return {sw,ne}
90
+ const LngLatBounds = function (sw, ne) {
91
+ return { sw, ne }
93
92
  }
94
- const bounds = spatial.getPaddedBounds(LngLatBounds,map)
93
+ const bounds = spatial.getPaddedBounds(LngLatBounds, map)
95
94
  expect(bounds.sw).toBeDefined()
96
95
  expect(bounds.ne).toBeDefined()
97
96
  })
@@ -140,4 +139,4 @@ describe('spatial utils', () => {
140
139
  expect(map.project).toHaveBeenCalledTimes(4)
141
140
  })
142
141
  })
143
- })
142
+ })
@@ -7,11 +7,11 @@ export const Actions = ({ children }) => {
7
7
  const { openPanels, panelConfig, breakpoint } = useApp()
8
8
 
9
9
  const childArray = React.Children.toArray(children)
10
- const visibleChild = childArray.find(c => c.props?.isHidden === false)
10
+ const visibleChild = childArray.find(c => c.props?.isHidden === false && c.props?.variant !== 'touch')
11
11
 
12
12
  // If a panel exists above we need so css adjustment
13
13
  const isBottomSlotUsed = Object.keys(openPanels).some(panelId => {
14
- return breakpoint === 'mobile' && panelConfig[panelId]?.[breakpoint]?.slot === 'bottom'
14
+ return breakpoint === 'mobile' && panelConfig[panelId]?.[breakpoint]?.slot === 'drawer'
15
15
  })
16
16
 
17
17
  const className = [
@@ -6,19 +6,12 @@
6
6
  padding: var(--panel-margin);
7
7
  max-height: 200px;
8
8
 
9
- @media (prefers-reduced-motion: no-preference) {
10
- transition: max-height var(--duration) ease, opacity var(--duration) ease,
11
- padding-top var(--duration) ease, padding-bottom var(--duration) ease;
12
- }
13
-
14
9
  &--border-top {
15
10
  border-top: 1px solid var(--app-border-color);
16
11
  }
17
12
 
18
13
  &--hidden {
19
14
  max-height: 0;
20
- overflow: hidden;
21
- opacity: 0;
22
15
  padding-top: 0;
23
16
  padding-bottom: 0;
24
17
  border: 0;
@@ -29,7 +29,7 @@ describe('Actions component', () => {
29
29
 
30
30
  it('adds the border class if a bottom slot panel is open', () => {
31
31
  mockUseApp.openPanels = { key: {} }
32
- mockUseApp.panelConfig = { key: { mobile: { slot: 'bottom' } } }
32
+ mockUseApp.panelConfig = { key: { mobile: { slot: 'drawer' } } }
33
33
 
34
34
  render(<Actions slot='actions'>Content</Actions>)
35
35
  const container = screen.getByText('Content').closest('div')
@@ -3,8 +3,8 @@ import { getIconRegistry } from '../../registry/iconRegistry.js'
3
3
 
4
4
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
5
5
  // sonarjs/disable-next-line function-name
6
- export const Icon = ({ id, svgContent }) => {
7
- const icon = getIconRegistry()[id] || svgContent
6
+ export const Icon = ({ id, svgContent, isMenu }) => {
7
+ const icon = isMenu ? getIconRegistry().chevron : (getIconRegistry()[id] || svgContent)
8
8
 
9
9
  return (
10
10
  <svg
@@ -20,6 +20,7 @@ export const Icon = ({ id, svgContent }) => {
20
20
  aria-hidden='true'
21
21
  focusable='false'
22
22
  dangerouslySetInnerHTML={{ __html: icon }}
23
+ className={`im-c-icon${isMenu ? ' im-c-icon--narrow' : ''}`}
23
24
  />
24
25
  )
25
26
  }
@@ -0,0 +1,4 @@
1
+ .im-c-icon--narrow {
2
+ margin-left: -3px;
3
+ margin-right: -3px;
4
+ }
@@ -33,27 +33,66 @@ describe('Icon component', () => {
33
33
  it('renders the SVG from the registry when id is provided', () => {
34
34
  getIconRegistry.mockReturnValue({ close: '<path d="M0 0 L10 10"/>' })
35
35
  const { container } = render(<Icon id='close' />)
36
- expect(container.querySelector('svg').innerHTML).toContain('<path d="M0 0 L10 10"')
36
+ expect(container.querySelector('svg').innerHTML)
37
+ .toContain('<path d="M0 0 L10 10"')
37
38
  })
38
39
 
39
40
  it('falls back to svgContent if id not found in registry', () => {
40
41
  getIconRegistry.mockReturnValue({})
41
42
  const fallbackSVG = '<circle cx="5" cy="5" r="5"/>'
42
43
  const { container } = render(<Icon id='unknown' svgContent={fallbackSVG} />)
43
- expect(container.querySelector('svg').innerHTML).toContain('<circle cx="5" cy="5" r="5"')
44
+ expect(container.querySelector('svg').innerHTML)
45
+ .toContain('<circle cx="5" cy="5" r="5"')
44
46
  })
45
47
 
46
48
  it('renders svgContent directly if no id provided', () => {
47
49
  const fallbackSVG = '<rect x="0" y="0" width="10" height="10"/>'
48
50
  getIconRegistry.mockReturnValue({})
49
51
  const { container } = render(<Icon svgContent={fallbackSVG} />)
50
- expect(container.querySelector('svg').innerHTML).toContain('<rect x="0" y="0" width="10" height="10"')
52
+ expect(container.querySelector('svg').innerHTML)
53
+ .toContain('<rect x="0" y="0" width="10" height="10"')
51
54
  })
52
55
 
53
56
  it('uses registry icon if both id and svgContent provided', () => {
54
57
  getIconRegistry.mockReturnValue({ check: '<path d="M1 1 L5 5"/>' })
55
58
  const fallbackSVG = '<circle cx="5" cy="5" r="5"/>'
56
59
  const { container } = render(<Icon id='check' svgContent={fallbackSVG} />)
57
- expect(container.querySelector('svg').innerHTML).toContain('<path d="M1 1 L5 5"')
60
+ expect(container.querySelector('svg').innerHTML)
61
+ .toContain('<path d="M1 1 L5 5"')
62
+ })
63
+
64
+ it('uses chevron icon when isMenu is true', () => {
65
+ getIconRegistry.mockReturnValue({
66
+ chevron: '<path d="M2 2 L8 8"/>',
67
+ close: '<path d="M0 0 L10 10"/>'
68
+ })
69
+ const { container } = render(<Icon id='close' isMenu />)
70
+ expect(container.querySelector('svg').innerHTML).toContain('<path d="M2 2 L8 8"')
71
+ })
72
+
73
+ it('ignores id and svgContent when isMenu is true', () => {
74
+ getIconRegistry.mockReturnValue({
75
+ chevron: '<path d="M2 2 L8 8"/>'
76
+ })
77
+ const fallbackSVG = '<circle cx="5" cy="5" r="5"/>'
78
+ const { container } = render(
79
+ <Icon id='close' svgContent={fallbackSVG} isMenu />
80
+ )
81
+ expect(container.querySelector('svg').innerHTML).toContain('<path d="M2 2 L8 8"')
82
+ })
83
+
84
+ it('adds narrow class when isMenu is true', () => {
85
+ getIconRegistry.mockReturnValue({
86
+ chevron: '<path d="M2 2 L8 8"/>'
87
+ })
88
+ const { container } = render(<Icon isMenu />)
89
+ const svg = container.querySelector('svg')
90
+ expect(svg).toHaveClass('im-c-icon--narrow')
91
+ })
92
+
93
+ it('renders nothing if isMenu is true and chevron is missing', () => {
94
+ getIconRegistry.mockReturnValue({})
95
+ const { container } = render(<Icon isMenu />)
96
+ expect(container.querySelector('svg').innerHTML).toBe('')
58
97
  })
59
98
  })
@@ -46,6 +46,37 @@ const handleKeyUp = (e) => {
46
46
  }
47
47
  }
48
48
 
49
+ const captureMenuRect = (buttonRefs, buttonId, setMenuRect) => {
50
+ const btn = buttonRefs.current[buttonId]
51
+ if (!btn) {
52
+ return
53
+ }
54
+ setMenuRect(btn.getBoundingClientRect().toJSON())
55
+ }
56
+
57
+ /**
58
+ * Returns a keyup handler for buttons that control a popup menu.
59
+ * ArrowDown opens the menu at the first item; ArrowUp opens at the last.
60
+ * @param {boolean} hasMenu - Whether the button has a popup menu
61
+ * @param {Object} buttonRefs - React ref map of button elements
62
+ * @param {string} buttonId - Unique button identifier
63
+ * @param {Function} setMenuStartPos - State setter for menu start position
64
+ * @param {Function} setMenuRect - State setter for button bounding rect
65
+ * @param {Function} setIsPopupOpen - State setter for popup open state
66
+ * @returns {Function} Keyboard event handler
67
+ */
68
+ const makePopupKeyUpHandler = (hasMenu, buttonRefs, buttonId, setMenuStartPos, setMenuRect, setIsPopupOpen) => (e) => {
69
+ if (hasMenu && ['ArrowDown', 'ArrowUp'].includes(e.key)) {
70
+ e.preventDefault()
71
+ setMenuStartPos(e.key === 'ArrowUp' ? 'last' : 'first')
72
+ captureMenuRect(buttonRefs, buttonId, setMenuRect)
73
+ setIsPopupOpen(true)
74
+ }
75
+ }
76
+
77
+ const getButtonSlot = (panelId, buttonId) =>
78
+ panelId ? `${stringToKebab(buttonId)}-button` : undefined
79
+
49
80
  /**
50
81
  * Determines the controlled element (panel or popup menu) for ARIA attributes.
51
82
  * @param {Object} options - Configuration options
@@ -177,10 +208,13 @@ export const MapButton = ({
177
208
  const { buttonRefs } = useApp()
178
209
  const [isPopupOpen, setIsPopupOpen] = useState(false)
179
210
  const [menuStartPos, setMenuStartPos] = useState(null)
211
+ const [menuRect, setMenuRect] = useState(null)
180
212
  const menuRef = useRef(null)
181
213
 
182
214
  const Element = href ? 'a' : 'button'
183
215
  const hasMenu = menuItems?.length >= 1
216
+ const showIcon = iconId || iconSvgContent || hasMenu
217
+ const buttonSlot = getButtonSlot(panelId, buttonId)
184
218
  const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu })
185
219
 
186
220
  /**
@@ -197,6 +231,9 @@ export const MapButton = ({
197
231
  const isKeyboard = e.nativeEvent.pointerType === ''
198
232
  /* istanbul ignore next as pointerType can't be tested in jest */
199
233
  setMenuStartPos(isKeyboard ? 'first' : null)
234
+ if (!isPopupOpen) {
235
+ captureMenuRect(buttonRefs, buttonId, setMenuRect)
236
+ }
200
237
  setIsPopupOpen((prev) => !prev)
201
238
  }
202
239
  if (onClick) {
@@ -204,19 +241,7 @@ export const MapButton = ({
204
241
  }
205
242
  }
206
243
 
207
- /**
208
- * Handles key up events on buttons that control popup menus.
209
- * ArrowDown opens the menu at the first item.
210
- * ArrowUp opens the menu at the last item.
211
- * @param {React.KeyboardEvent} e - The keyboard event
212
- */
213
- const handleButtonKeyUp = e => {
214
- if (hasMenu && ['ArrowDown', 'ArrowUp'].includes(e.key)) {
215
- e.preventDefault()
216
- setMenuStartPos(e.key === 'ArrowUp' ? 'last' : 'first')
217
- setIsPopupOpen(true)
218
- }
219
- }
244
+ const handleButtonKeyUp = makePopupKeyUpHandler(hasMenu, buttonRefs, buttonId, setMenuStartPos, setMenuRect, setIsPopupOpen)
220
245
 
221
246
  const buttonProps = buildButtonProps({
222
247
  appId,
@@ -236,7 +261,7 @@ export const MapButton = ({
236
261
 
237
262
  const buttonEl = (
238
263
  <Element {...buttonProps}>
239
- {(iconId || iconSvgContent) && <Icon id={iconId} svgContent={iconSvgContent} />}
264
+ {showIcon && <Icon id={iconId} svgContent={iconSvgContent} isMenu={hasMenu} />}
240
265
  {showLabel && <span>{label}</span>}
241
266
  </Element>
242
267
  )
@@ -244,12 +269,12 @@ export const MapButton = ({
244
269
  return (
245
270
  <div
246
271
  className={buildWrapperClassNames(buttonId, showLabel)}
247
- data-button-slot={panelId ? `${stringToKebab(buttonId)}-button` : undefined}
272
+ data-button-slot={buttonSlot}
248
273
  style={isHidden ? { display: 'none' } : undefined}
249
274
  >
250
275
  {showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
251
- {panelId && <SlotRenderer slot={`${stringToKebab(buttonId)}-button`} />}
252
- {isPopupOpen && <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} />}
276
+ {buttonSlot && <SlotRenderer slot={buttonSlot} />}
277
+ {isPopupOpen && <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} buttonRect={menuRect} />}
253
278
  </div>
254
279
  )
255
280
  }
@@ -124,19 +124,6 @@
124
124
  }
125
125
  }
126
126
 
127
- .im-c-map-button--touch {
128
- background-color: var(--map-overlay-foreground-color);
129
- color: var(--map-overlay-halo-color);
130
-
131
- width: var(--touch-button-size);
132
- height: var(--touch-button-size);
133
- border-radius: 100%;
134
-
135
- &::before {
136
- box-shadow: none;
137
- }
138
- }
139
-
140
127
  .im-o-app__right {
141
128
  .im-c-button-group {
142
129
  display: flex;
@@ -185,6 +172,10 @@
185
172
  }
186
173
 
187
174
  // 4. State styles
175
+ .im-c-map-button[aria-haspopup="true"][aria-expanded="true"] svg {
176
+ transform: rotate(180deg);
177
+ }
178
+
188
179
  .im-c-map-button[aria-disabled="true"]:not(.im-c-map-button--primary):not(.im-c-map-button--tertiary) {
189
180
  svg, span {
190
181
  opacity: var(--disabled-button-opacity);
@@ -23,7 +23,7 @@ jest.mock('../../renderer/SlotRenderer', () => ({
23
23
  }))
24
24
 
25
25
  jest.mock('../PopupMenu/PopupMenu', () => ({
26
- PopupMenu: ({ startPos, items }) => {
26
+ PopupMenu: ({ startPos, items, buttonRect }) => {
27
27
  let selectedIndex = -1
28
28
  if (startPos === 'first' && items?.length > 0) {
29
29
  selectedIndex = 0
@@ -31,7 +31,7 @@ jest.mock('../PopupMenu/PopupMenu', () => ({
31
31
  if (startPos === 'last' && items?.length > 0) {
32
32
  selectedIndex = items.length - 1
33
33
  }
34
- return <div data-testid='popup-menu' data-start-pos={String(startPos)} data-selected-index={String(selectedIndex)}>{items?.map((item, i) => <div key={i} data-testid={`menu-item-${i}`}>{item.label}</div>)}</div>
34
+ return <div data-testid='popup-menu' data-start-pos={String(startPos)} data-selected-index={String(selectedIndex)} data-has-rect={String(!!buttonRect)}>{items?.map((item, i) => <div key={i} data-testid={`menu-item-${i}`}>{item.label}</div>)}</div>
35
35
  }
36
36
  }))
37
37
 
@@ -41,7 +41,12 @@ const mockButtonRefs = { current: {} }
41
41
  jest.mock('../../store/appContext', () => ({ useApp: () => ({ buttonRefs: mockButtonRefs }) }))
42
42
 
43
43
  describe('MapButton', () => {
44
- beforeEach(() => { mockButtonRefs.current = {} })
44
+ beforeEach(() => {
45
+ mockButtonRefs.current = {}
46
+ Element.prototype.getBoundingClientRect = jest.fn(() => ({
47
+ toJSON: () => ({ top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 })
48
+ }))
49
+ })
45
50
 
46
51
  const renderButton = (props = {}) => render(<MapButton buttonId='Test' iconId='icon' label='Label' {...props} />)
47
52
  const getButton = () => screen.getByRole('button')
@@ -148,6 +153,19 @@ describe('MapButton', () => {
148
153
  expect(menu).toHaveAttribute('data-selected-index', String(expectedIndex))
149
154
  })
150
155
 
156
+ it('passes buttonRect to popup menu on open', () => {
157
+ renderButton({ menuItems: [{ label: 'Item' }] })
158
+ fireEvent.click(getButton())
159
+ expect(screen.getByTestId('popup-menu')).toHaveAttribute('data-has-rect', 'true')
160
+ })
161
+
162
+ it('captureMenuRect returns early when buttonRef is not stored', () => {
163
+ renderButton({ menuItems: [{ label: 'Item' }] })
164
+ mockButtonRefs.current = {}
165
+ fireEvent.click(getButton())
166
+ expect(screen.getByTestId('popup-menu')).toHaveAttribute('data-has-rect', 'false')
167
+ })
168
+
151
169
  it('does nothing for arrow keys when no menu', () => {
152
170
  renderButton()
153
171
  fireEvent.keyUp(getButton(), { key: 'ArrowDown' })
@@ -188,4 +206,10 @@ describe('MapButton', () => {
188
206
  fireEvent.keyUp(el, { key: 'Enter' })
189
207
  expect(spy).not.toHaveBeenCalled()
190
208
  })
209
+
210
+ it('renders no Icon when iconId, iconSvgContent and menuItems are all absent', () => {
211
+ render(<MapButton buttonId='Test' label='Label' />)
212
+ expect(screen.queryByRole('img', { hidden: true })).toBeNull()
213
+ expect(screen.queryByTestId('icon')).toBeNull()
214
+ })
191
215
  })