@defra/interactive-map 0.0.30-alpha → 0.0.32-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 (247) hide show
  1. package/README.md +2 -2
  2. package/assets/images/bright-ofm-thumb.jpg +0 -0
  3. package/assets/images/dark-ofm-thumb.jpg +0 -0
  4. package/assets/images/road-ofm-thumb.jpg +0 -0
  5. package/assets/templates/add-polygons.njk +143 -0
  6. package/assets/templates/add-symbols.njk +119 -0
  7. package/assets/templates/{map.njk → basic-map.njk} +9 -17
  8. package/assets/templates/button-map.njk +56 -0
  9. package/assets/templates/draw-tools.njk +161 -0
  10. package/assets/templates/marker-panel.njk +91 -0
  11. package/assets/templates/place-marker.njk +104 -0
  12. package/assets/templates/search-control.njk +98 -0
  13. package/assets/templates/select-feature.njk +101 -0
  14. package/assets/templates/style-switcher.njk +87 -0
  15. package/assets/templates/toggle-marker-label.njk +74 -0
  16. package/dist/css/index.css +1 -1
  17. package/dist/esm/im-core.js +1 -1
  18. package/dist/esm/im-shell.js +1 -1
  19. package/dist/umd/im-core.js +1 -1
  20. package/dist/umd/index.js +1 -1
  21. package/docs/api/context.md +4 -4
  22. package/docs/api.md +166 -37
  23. package/docs/assets/css/docusaurus.scss +36 -4
  24. package/docs/assets/images/add-polygons.jpg +0 -0
  25. package/docs/assets/images/add-symbols.jpg +0 -0
  26. package/docs/assets/images/basic-map.jpg +0 -0
  27. package/docs/assets/images/button-first.jpg +0 -0
  28. package/docs/assets/images/deuteranopia-ozs-map-thumb.jpg +0 -0
  29. package/docs/assets/images/draw.jpg +0 -0
  30. package/docs/assets/images/marker-panel.jpg +0 -0
  31. package/docs/assets/images/night-ozs-map-thumb.jpg +0 -0
  32. package/docs/assets/images/outdoor-ozs-map-thumb.jpg +0 -0
  33. package/docs/assets/images/place-marker.jpg +0 -0
  34. package/docs/assets/images/screenshot.jpg +0 -0
  35. package/docs/assets/images/search.jpg +0 -0
  36. package/docs/assets/images/select-feature.jpg +0 -0
  37. package/docs/assets/images/style-switcher.jpg +0 -0
  38. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  39. package/docs/assets/images/tritanopia-ozs-map-thumb.jpg +0 -0
  40. package/docs/examples/add-marker-with-panel.mdx +3 -3
  41. package/docs/examples/add-polygons.mdx +225 -0
  42. package/docs/examples/add-symbols.mdx +177 -0
  43. package/docs/examples/basic-map.mdx +2 -2
  44. package/docs/examples/draw-tools.mdx +271 -0
  45. package/docs/examples/index.mdx +95 -11
  46. package/docs/examples/place-marker.mdx +151 -0
  47. package/docs/examples/search-control.mdx +71 -0
  48. package/docs/examples/select-feature.mdx +146 -0
  49. package/docs/examples/style-switcher.mdx +135 -0
  50. package/docs/examples/toggle-marker-label.mdx +3 -3
  51. package/docs/plugins/datasets.md +157 -122
  52. package/docs/plugins/interact.md +1 -4
  53. package/docusaurus.config.cjs +9 -2
  54. package/govuk-prototype-kit.config.json +52 -2
  55. package/package.json +1 -1
  56. package/plugins/beta/datasets/dist/css/index.css +5 -2
  57. package/plugins/beta/datasets/dist/esm/im-datasets-ml-adapter.js +1 -0
  58. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  59. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  60. package/plugins/beta/datasets/dist/umd/im-datasets-ml-adapter.js +2 -0
  61. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -0
  62. package/plugins/beta/datasets/dist/umd/index.js +2 -0
  63. package/plugins/beta/datasets/src/adapters/loadLayerAdapter.js +33 -0
  64. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +44 -120
  65. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +145 -245
  66. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +516 -0
  67. package/plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.js +209 -0
  68. package/plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.test.js +483 -0
  69. package/plugins/beta/datasets/src/api/addDataset.js +2 -5
  70. package/plugins/beta/datasets/src/api/addDataset.test.js +16 -0
  71. package/plugins/beta/datasets/src/api/getOpacity.js +14 -14
  72. package/plugins/beta/datasets/src/api/getOpacity.test.js +72 -0
  73. package/plugins/beta/datasets/src/api/getStyle.js +8 -9
  74. package/plugins/beta/datasets/src/api/getStyle.test.js +42 -0
  75. package/plugins/beta/datasets/src/api/removeDataset.js +7 -7
  76. package/plugins/beta/datasets/src/api/removeDataset.test.js +45 -0
  77. package/plugins/beta/datasets/src/api/setData.js +9 -3
  78. package/plugins/beta/datasets/src/api/setData.test.js +62 -0
  79. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +5 -30
  80. package/plugins/beta/datasets/src/api/setDatasetVisibility.test.js +31 -0
  81. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +9 -18
  82. package/plugins/beta/datasets/src/api/setFeatureVisibility.test.js +74 -0
  83. package/plugins/beta/datasets/src/api/setGlobals.js +20 -0
  84. package/plugins/beta/datasets/src/api/setGlobals.test.js +63 -0
  85. package/plugins/beta/datasets/src/api/setOpacity.js +8 -27
  86. package/plugins/beta/datasets/src/api/setOpacity.test.js +31 -0
  87. package/plugins/beta/datasets/src/api/setStyle.js +5 -21
  88. package/plugins/beta/datasets/src/api/setStyle.test.js +53 -0
  89. package/plugins/beta/datasets/src/components/Key/Key.jsx +51 -0
  90. package/plugins/beta/datasets/src/components/Key/KeyGroupItem.jsx +18 -0
  91. package/plugins/beta/datasets/src/components/Key/KeyItem.jsx +20 -0
  92. package/plugins/beta/datasets/src/components/Key/KeySvg.jsx +22 -0
  93. package/plugins/beta/datasets/src/components/Key/KeySvg.test.jsx +87 -0
  94. package/plugins/beta/datasets/src/components/Key/KeySvgLine.jsx +19 -0
  95. package/plugins/beta/datasets/src/components/Key/KeySvgLine.test.jsx +71 -0
  96. package/plugins/beta/datasets/src/components/{KeySvgPattern.jsx → Key/KeySvgPattern.jsx} +3 -4
  97. package/plugins/beta/datasets/src/components/Key/KeySvgPattern.test.jsx +55 -0
  98. package/plugins/beta/datasets/src/components/Key/KeySvgRect.jsx +22 -0
  99. package/plugins/beta/datasets/src/components/Key/KeySvgRect.test.jsx +85 -0
  100. package/plugins/beta/datasets/src/components/{KeySvgSymbol.jsx → Key/KeySvgSymbol.jsx} +5 -5
  101. package/plugins/beta/datasets/src/components/Key/KeySvgSymbol.test.jsx +80 -0
  102. package/plugins/beta/datasets/src/components/Key/svgProperties.test.js +61 -0
  103. package/plugins/beta/datasets/src/{panels → components/LayersMenu}/Layers.module.scss +15 -11
  104. package/plugins/beta/datasets/src/components/LayersMenu/LayersMenu.jsx +33 -0
  105. package/plugins/beta/datasets/src/components/LayersMenu/LayersMenuCheckbox.jsx +31 -0
  106. package/plugins/beta/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx +19 -0
  107. package/plugins/beta/datasets/src/datasets.scss +2 -2
  108. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +8 -8
  109. package/plugins/beta/datasets/src/{DatasetsInit.jsx → initialise/DatasetsInit.jsx} +19 -21
  110. package/plugins/beta/datasets/src/{defaults.js → initialise/defaults.js} +17 -2
  111. package/plugins/beta/datasets/src/initialise/defaults.test.js +127 -0
  112. package/plugins/beta/datasets/src/{datasets.js → initialise/initialiseDatasets.js} +30 -26
  113. package/plugins/beta/datasets/src/initialise/initialiseDatasets.test.js +273 -0
  114. package/plugins/beta/datasets/src/manifest.js +8 -6
  115. package/plugins/beta/datasets/src/reducers/__data__/demoDatasets.js +253 -0
  116. package/plugins/beta/datasets/src/reducers/datasetsToMenu.js +83 -0
  117. package/plugins/beta/datasets/src/reducers/datasetsToMenu.test.js +84 -0
  118. package/plugins/beta/datasets/src/reducers/mappedDatasetsReducer.js +41 -0
  119. package/plugins/beta/datasets/src/reducers/mappedDatasetsReducer.test.js +117 -0
  120. package/plugins/beta/datasets/src/reducers/pluginState.js +206 -0
  121. package/plugins/beta/datasets/src/reducers/pluginState.test.js +386 -0
  122. package/plugins/beta/datasets/src/registry/__mocks__/datasetRegistry.js +29 -0
  123. package/plugins/beta/datasets/src/registry/createDataset.js +5 -0
  124. package/plugins/beta/datasets/src/registry/dataset.js +176 -0
  125. package/plugins/beta/datasets/src/registry/dataset.test.js +497 -0
  126. package/plugins/beta/datasets/src/registry/datasetDefinitionCache.js +35 -0
  127. package/plugins/beta/datasets/src/registry/datasetRegistry.js +116 -0
  128. package/plugins/beta/datasets/src/registry/datasetRegistry.test.js +335 -0
  129. package/plugins/beta/datasets/src/registry/dynamicGeoJson.js +17 -0
  130. package/plugins/beta/datasets/src/registry/globalDataset.js +36 -0
  131. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  132. package/plugins/beta/draw-es/src/api/addFeature.js +12 -3
  133. package/plugins/beta/draw-es/src/events.js +8 -3
  134. package/plugins/beta/draw-es/src/events.test.js +2 -0
  135. package/plugins/beta/draw-es/src/graphic.js +26 -2
  136. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  137. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  138. package/plugins/beta/draw-ml/src/DrawInit.jsx +8 -0
  139. package/plugins/beta/draw-ml/src/api/newLine.js +1 -0
  140. package/plugins/beta/draw-ml/src/api/newPolygon.js +1 -0
  141. package/plugins/beta/draw-ml/src/events.js +13 -1
  142. package/plugins/beta/draw-ml/src/index.js +1 -1
  143. package/plugins/beta/draw-ml/src/manifest.js +3 -0
  144. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +21 -12
  145. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +4 -0
  146. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -1
  147. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  148. package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +4 -2
  149. package/plugins/beta/map-styles/src/mapStyles.scss +41 -41
  150. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  151. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  152. package/plugins/interact/dist/umd/index.js +1 -1
  153. package/plugins/interact/src/InteractInit.jsx +3 -5
  154. package/plugins/interact/src/InteractInit.test.js +1 -3
  155. package/plugins/interact/src/events.js +2 -29
  156. package/plugins/interact/src/events.test.js +4 -102
  157. package/plugins/interact/src/hooks/useAttachEvents.js +3 -4
  158. package/plugins/interact/src/hooks/useAttachEvents.test.js +1 -3
  159. package/plugins/interact/src/hooks/useCrossHairVisibility.js +4 -10
  160. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +5 -10
  161. package/plugins/interact/src/hooks/useHighlightSync.js +1 -10
  162. package/plugins/interact/src/hooks/useHighlightSync.test.js +2 -21
  163. package/plugins/interact/src/hooks/useInteractionHandlers.js +15 -12
  164. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +28 -29
  165. package/plugins/interact/src/manifest.js +0 -35
  166. package/plugins/interact/src/manifest.test.js +3 -43
  167. package/plugins/interact/src/reducer.js +4 -22
  168. package/plugins/interact/src/reducer.test.js +11 -39
  169. package/plugins/search/dist/css/index.css +5 -2
  170. package/plugins/search/src/components/Form/Form.module.scss +5 -2
  171. package/plugins/search/src/search.scss +4 -4
  172. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  173. package/providers/beta/esri/src/esriProvider.js +4 -0
  174. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  175. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  176. package/providers/beta/openlayers/src/openlayersProvider.js +4 -0
  177. package/providers/beta/openlayers/src/openlayersProvider.test.js +5 -0
  178. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  179. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  180. package/providers/maplibre/src/maplibreProvider.js +6 -4
  181. package/providers/maplibre/src/maplibreProvider.test.js +5 -0
  182. package/providers/maplibre/src/utils/highlightFeatures.js +74 -25
  183. package/providers/maplibre/src/utils/highlightFeatures.test.js +132 -0
  184. package/providers/maplibre/src/utils/patternImages.js +2 -4
  185. package/providers/maplibre/src/utils/symbolImages.js +1 -4
  186. package/rollup.esm.mjs +2 -8
  187. package/src/App/components/CrossHair/CrossHair.module.scss +8 -8
  188. package/src/App/components/MapButton/MapButton.jsx +16 -1
  189. package/src/App/components/MapButton/MapButton.module.scss +12 -1
  190. package/src/App/components/MapButton/MapButton.test.jsx +40 -1
  191. package/src/App/hooks/useContinueEnabledEvaluator.js +31 -0
  192. package/src/App/hooks/useContinueEnabledEvaluator.test.js +118 -0
  193. package/src/App/hooks/useEvaluateProp.js +15 -15
  194. package/src/App/hooks/useEvaluateProp.test.js +80 -76
  195. package/src/App/hooks/useLayoutMeasurements.js +11 -5
  196. package/src/App/hooks/useResizeObserver.js +15 -2
  197. package/src/App/hooks/useResizeObserver.test.js +1 -0
  198. package/src/App/renderer/PluginInits.jsx +2 -0
  199. package/src/App/renderer/mapButtons.js +21 -1
  200. package/src/App/renderer/mapButtons.test.js +46 -1
  201. package/src/App/store/appReducer.js +1 -1
  202. package/src/App/store/appReducer.test.js +37 -0
  203. package/src/InteractiveMap/InteractiveMap.js +14 -4
  204. package/src/InteractiveMap/InteractiveMap.test.js +11 -3
  205. package/src/InteractiveMap/behaviourController.js +3 -1
  206. package/src/InteractiveMap/behaviourController.test.js +6 -3
  207. package/src/InteractiveMap/domStateManager.js +2 -0
  208. package/src/config/appConfig.js +25 -0
  209. package/src/config/appConfig.test.js +50 -0
  210. package/src/config/defaults.js +1 -0
  211. package/src/config/events.js +21 -3
  212. package/src/scss/settings/_dimensions.scss +1 -0
  213. package/src/services/patternRegistry.js +8 -4
  214. package/src/types.js +7 -0
  215. package/src/utils/patternUtils.js +1 -2
  216. package/src/utils/patternUtils.test.js +9 -0
  217. package/webpack.dev.mjs +10 -0
  218. package/webpack.umd.mjs +1 -1
  219. package/docs/assets/images/screens-blue.jpg +0 -0
  220. package/docs/assets/images/screens-white.jpg +0 -0
  221. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +0 -1
  222. package/plugins/beta/datasets/dist/adapters/maplibre/esm/index.js +0 -1
  223. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +0 -2
  224. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +0 -2
  225. package/plugins/beta/datasets/dist/umd/maplibre/index.js +0 -2
  226. package/plugins/beta/datasets/src/adapters/maplibre/index.js +0 -18
  227. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +0 -75
  228. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +0 -27
  229. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +0 -31
  230. package/plugins/beta/datasets/src/components/KeySvg.jsx +0 -24
  231. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +0 -19
  232. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +0 -22
  233. package/plugins/beta/datasets/src/index.maplibre.umd.js +0 -23
  234. package/plugins/beta/datasets/src/panels/Key.jsx +0 -62
  235. package/plugins/beta/datasets/src/panels/Layers.jsx +0 -141
  236. package/plugins/beta/datasets/src/reducer.js +0 -219
  237. package/plugins/beta/datasets/src/reducers/keyReducer.js +0 -34
  238. package/plugins/beta/datasets/src/utils/filters.js +0 -34
  239. package/plugins/beta/datasets/src/utils/mergeSublayer.js +0 -86
  240. package/plugins/beta/draw-ml/dist/css/index.css +0 -21
  241. /package/plugins/beta/datasets/dist/umd/{maplibre/im-datasets-maplibre-adapter.js.LICENSE.txt → im-datasets-ml-adapter.js.LICENSE.txt} +0 -0
  242. /package/plugins/beta/datasets/dist/umd/{maplibre/im-datasets-plugin.js.LICENSE.txt → im-datasets-plugin.js.LICENSE.txt} +0 -0
  243. /package/plugins/beta/datasets/dist/umd/{maplibre/index.js.LICENSE.txt → index.js.LICENSE.txt} +0 -0
  244. /package/plugins/beta/datasets/src/components/{EmptyKey.jsx → Key/EmptyKey.jsx} +0 -0
  245. /package/plugins/beta/datasets/src/components/{EmptyKey.test.jsx → Key/EmptyKey.test.jsx} +0 -0
  246. /package/plugins/beta/datasets/src/{panels → components/Key}/Key.module.scss +0 -0
  247. /package/plugins/beta/datasets/src/components/{svgProperties.js → Key/svgProperties.js} +0 -0
@@ -23,25 +23,25 @@ export function useEvaluateProp () {
23
23
  iconRegistry: getIconRegistry()
24
24
  }
25
25
 
26
- function evaluateProp (prop, pluginId) {
27
- let pluginConfig
28
- let pluginState
29
-
30
- if (pluginId) {
31
- const pluginEntry = appConfig.pluginRegistry.registeredPlugins.find(p => p.id === pluginId)
32
- pluginConfig = pluginEntry
33
- ? {
34
- pluginId: pluginEntry.id,
35
- ...pluginEntry.config
36
- }
37
- : {}
38
- // Only include this plugin's state + dispatch
26
+ function buildPluginContext (pluginId) {
27
+ if (!pluginId) { return { pluginStates: pluginContext?.state ?? {} } }
28
+
29
+ const pluginEntry = appConfig.pluginRegistry.registeredPlugins.find(p => p.id === pluginId)
30
+ const pluginConfig = pluginEntry ? { pluginId: pluginEntry.id, ...pluginEntry.config } : {}
31
+
32
+ // Only include isolated plugin state when the plugin has registered a reducer.
33
+ // Framework entries like 'appConfig' have no reducer so their buttons get pluginStates instead.
34
+ if (Object.hasOwn(pluginContext?.state ?? {}, pluginId)) {
39
35
  const stateForPlugin = pluginContext?.state?.[pluginId] ?? {}
40
- pluginState = { ...stateForPlugin, dispatch: pluginContext?.dispatch }
36
+ const pluginState = { ...stateForPlugin, dispatch: pluginContext?.dispatch }
37
+ return { pluginConfig, pluginState }
41
38
  }
42
39
 
43
- const fullContext = { ...ctx, pluginConfig, pluginState }
40
+ return { pluginConfig, pluginStates: pluginContext?.state ?? {} }
41
+ }
44
42
 
43
+ function evaluateProp (prop, pluginId) {
44
+ const fullContext = { ...ctx, ...buildPluginContext(pluginId) }
45
45
  return typeof prop === 'function' ? prop(fullContext) : prop
46
46
  }
47
47
 
@@ -8,57 +8,50 @@ import * as serviceStore from '../store/serviceContext.js'
8
8
  import { PluginContext } from '../store/PluginProvider.jsx'
9
9
  import * as iconRegistryModule from '../registry/iconRegistry.js'
10
10
 
11
- // --- Mock the dependencies ---
12
11
  jest.mock('../store/configContext.js', () => ({ useConfig: jest.fn() }))
13
12
  jest.mock('../store/appContext.js', () => ({ useApp: jest.fn() }))
14
13
  jest.mock('../store/mapContext.js', () => ({ useMap: jest.fn() }))
15
14
  jest.mock('../store/serviceContext.js', () => ({ useService: jest.fn() }))
16
15
  jest.mock('../registry/iconRegistry.js', () => ({ getIconRegistry: jest.fn() }))
17
16
 
18
- describe('useEvaluateProp hook', () => {
19
- const pluginDispatch = jest.fn()
20
- const mockPluginRegistry = {
21
- registeredPlugins: [],
22
- registerPlugin: jest.fn(),
23
- clear: jest.fn()
24
- }
25
-
26
- const pluginContextValue = {
27
- state: { myPlugin: { foo: 'bar' } },
28
- dispatch: pluginDispatch
29
- }
30
-
31
- beforeEach(() => {
32
- jest.clearAllMocks()
33
- mockPluginRegistry.registeredPlugins = []
34
- configStore.useConfig.mockReturnValue({
35
- mapProvider: { name: 'leaflet' },
36
- pluginRegistry: mockPluginRegistry
37
- })
38
- appStore.useApp.mockReturnValue({ user: 'alice' })
39
- mapStore.useMap.mockReturnValue({ zoom: 5 })
40
- serviceStore.useService.mockReturnValue({ reverseGeocode: jest.fn() })
41
- iconRegistryModule.getIconRegistry.mockReturnValue({ close: '<svg/>' })
42
- })
17
+ const RAW_VALUE = 'raw-test-value'
18
+ const pluginDispatch = jest.fn()
19
+ const pluginContextValue = {
20
+ state: { myPlugin: { foo: 'bar' } },
21
+ dispatch: pluginDispatch
22
+ }
23
+ const mockPluginRegistry = {
24
+ registeredPlugins: [],
25
+ registerPlugin: jest.fn(),
26
+ clear: jest.fn()
27
+ }
28
+
29
+ const withPluginContext = ({ children }) => (
30
+ <PluginContext.Provider value={pluginContextValue}>
31
+ {children}
32
+ </PluginContext.Provider>
33
+ )
34
+
35
+ beforeEach(() => {
36
+ jest.clearAllMocks()
37
+ mockPluginRegistry.registeredPlugins = []
38
+ configStore.useConfig.mockReturnValue({ mapProvider: { name: 'leaflet' }, pluginRegistry: mockPluginRegistry })
39
+ appStore.useApp.mockReturnValue({ user: 'alice' })
40
+ mapStore.useMap.mockReturnValue({ zoom: 5 })
41
+ serviceStore.useService.mockReturnValue({ reverseGeocode: jest.fn() })
42
+ iconRegistryModule.getIconRegistry.mockReturnValue({ close: '<svg/>' })
43
+ })
43
44
 
45
+ describe('useEvaluateProp — basic evaluation', () => {
44
46
  it('returns the raw prop if it is not a function', () => {
45
47
  const { result } = renderHook(() => useEvaluateProp())
46
- const evaluate = result.current
47
- const value = evaluate(42)
48
- expect(value).toBe(42)
48
+ expect(result.current(RAW_VALUE)).toBe(RAW_VALUE)
49
49
  })
50
50
 
51
51
  it('calls prop function with full context when no pluginId provided', () => {
52
52
  const { result } = renderHook(() => useEvaluateProp())
53
- const evaluate = result.current
54
-
55
53
  const fn = jest.fn(ctx => ctx.appState.user)
56
-
57
- const value = evaluate(fn)
58
-
59
- expect(fn).toHaveBeenCalled()
60
- expect(value).toBe('alice')
61
-
54
+ expect(result.current(fn)).toBe('alice')
62
55
  expect(fn.mock.calls[0][0]).toMatchObject({
63
56
  appConfig: { mapProvider: { name: 'leaflet' } },
64
57
  appState: { user: 'alice' },
@@ -69,56 +62,67 @@ describe('useEvaluateProp hook', () => {
69
62
  })
70
63
  })
71
64
 
72
- it('includes pluginConfig and pluginState when pluginId provided', () => {
73
- mockPluginRegistry.registeredPlugins.push({ id: 'myPlugin', config: { includeModes: ['edit'], excludeModes: ['view'] } })
65
+ it('exposes ctx property on the returned evaluateProp function', () => {
66
+ const { result } = renderHook(() => useEvaluateProp())
67
+ expect(result.current.ctx).toMatchObject({
68
+ appConfig: { mapProvider: { name: 'leaflet' } },
69
+ appState: { user: 'alice' },
70
+ mapState: { zoom: 5 },
71
+ services: { reverseGeocode: expect.any(Function) },
72
+ mapProvider: { name: 'leaflet' },
73
+ iconRegistry: { close: '<svg/>' }
74
+ })
75
+ })
76
+ })
74
77
 
75
- const wrapper = ({ children }) => (
76
- <PluginContext.Provider value={pluginContextValue}>
77
- {children}
78
- </PluginContext.Provider>
79
- )
78
+ describe('useEvaluateProp pluginStates (core/framework buttons)', () => {
79
+ it('includes pluginStates in context when no pluginId provided', () => {
80
+ const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext })
81
+ expect(result.current(ctx => ctx.pluginStates)).toEqual({ myPlugin: { foo: 'bar' } })
82
+ })
80
83
 
81
- const { result } = renderHook(() => useEvaluateProp(), { wrapper })
82
- const evaluate = result.current
84
+ it('includes pluginStates for appConfig plugin (no reducer, not in state)', () => {
85
+ mockPluginRegistry.registeredPlugins.push({ id: 'appConfig', config: {} })
86
+ const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext })
87
+ expect(result.current(ctx => ctx.pluginStates, 'appConfig')).toEqual({ myPlugin: { foo: 'bar' } })
88
+ })
83
89
 
84
- const fn = jest.fn(ctx => ctx.pluginState.foo)
85
- const value = evaluate(fn, 'myPlugin')
90
+ it('falls back to empty pluginStates when PluginContext is not provided', () => {
91
+ mockPluginRegistry.registeredPlugins.push({ id: 'appConfig', config: {} })
92
+ const { result } = renderHook(() => useEvaluateProp())
93
+ expect(result.current(ctx => ctx.pluginStates, 'appConfig')).toEqual({})
94
+ })
95
+ })
86
96
 
87
- expect(fn).toHaveBeenCalled()
88
- expect(value).toBe('bar')
89
- const ctxPassed = fn.mock.calls[0][0]
90
- expect(ctxPassed.pluginConfig).toEqual({
91
- pluginId: 'myPlugin',
92
- includeModes: ['edit'],
93
- excludeModes: ['view']
94
- })
95
- expect(ctxPassed.pluginState).toMatchObject({ foo: 'bar', dispatch: pluginDispatch })
97
+ describe('useEvaluateProp — plugin state isolation', () => {
98
+ it('includes pluginConfig and pluginState for real plugin buttons', () => {
99
+ mockPluginRegistry.registeredPlugins.push({ id: 'myPlugin', config: { includeModes: ['edit'], excludeModes: ['view'] } })
100
+ const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext })
101
+ const ctx = result.current(c => c, 'myPlugin')
102
+ expect(ctx.pluginConfig).toEqual({ pluginId: 'myPlugin', includeModes: ['edit'], excludeModes: ['view'] })
103
+ expect(ctx.pluginState).toMatchObject({ foo: 'bar', dispatch: pluginDispatch })
104
+ })
105
+
106
+ it('does not include pluginStates for real plugin buttons', () => {
107
+ mockPluginRegistry.registeredPlugins.push({ id: 'myPlugin', config: {} })
108
+ const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext })
109
+ expect(result.current(ctx => ctx.pluginStates, 'myPlugin')).toBeUndefined()
96
110
  })
97
111
 
98
112
  it('returns empty pluginConfig if plugin not registered', () => {
113
+ const { result } = renderHook(() => useEvaluateProp(), { wrapper: withPluginContext })
114
+ expect(result.current(ctx => ctx.pluginConfig, 'unknownPlugin')).toEqual({})
115
+ })
116
+
117
+ it('falls back to empty object when plugin state entry is null', () => {
118
+ mockPluginRegistry.registeredPlugins.push({ id: 'myPlugin', config: {} })
99
119
  const wrapper = ({ children }) => (
100
- <PluginContext.Provider value={pluginContextValue}>
120
+ <PluginContext.Provider value={{ state: { myPlugin: null }, dispatch: pluginDispatch }}>
101
121
  {children}
102
122
  </PluginContext.Provider>
103
123
  )
104
124
  const { result } = renderHook(() => useEvaluateProp(), { wrapper })
105
- const evaluate = result.current
106
-
107
- const fn = jest.fn(ctx => ctx.pluginConfig)
108
- const value = evaluate(fn, 'unknownPlugin')
109
- expect(value).toEqual({})
110
- })
111
-
112
- it('exposes ctx property on the returned evaluateProp function', () => {
113
- const { result } = renderHook(() => useEvaluateProp())
114
- const evaluate = result.current
115
- expect(evaluate.ctx).toMatchObject({
116
- appConfig: { mapProvider: { name: 'leaflet' } },
117
- appState: { user: 'alice' },
118
- mapState: { zoom: 5 },
119
- services: { reverseGeocode: expect.any(Function) },
120
- mapProvider: { name: 'leaflet' },
121
- iconRegistry: { close: '<svg/>' }
122
- })
125
+ const ctx = result.current(c => c, 'myPlugin')
126
+ expect(ctx.pluginState).toMatchObject({ dispatch: pluginDispatch })
123
127
  })
124
128
  })
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect } from 'react'
1
+ import { useLayoutEffect, useMemo } from 'react'
2
2
  import { useResizeObserver } from './useResizeObserver.js'
3
3
  import { useApp } from '../store/appContext.js'
4
4
  import { useMap } from '../store/mapContext.js'
@@ -153,9 +153,15 @@ export function useLayoutMeasurements () {
153
153
  // actions buttons, etc.). Safe zone is intentionally not dispatched here —
154
154
  // that is Effect 2's responsibility.
155
155
  // --------------------------------
156
- useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef], () => {
157
- requestAnimationFrame(() => {
158
- calculateLayout(layoutRefs)
159
- })
156
+ // Stable reference all entries are ref objects from useApp that never change.
157
+ // Prevents useResizeObserver's effect from re-running (and cancelling its RAF)
158
+ // on every render due to a new array literal being passed each time.
159
+ const observedRefs = useMemo(
160
+ () => [bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef],
161
+ []
162
+ )
163
+
164
+ useResizeObserver(observedRefs, () => {
165
+ calculateLayout(layoutRefs)
160
166
  })
161
167
  }
@@ -3,6 +3,8 @@ import { useEffect, useRef } from 'react'
3
3
  export function useResizeObserver (targetRefs, callback) {
4
4
  const frameRef = useRef()
5
5
  const prevSizes = useRef(new WeakMap()) // track sizes per element
6
+ const callbackRef = useRef(callback)
7
+ callbackRef.current = callback
6
8
 
7
9
  useEffect(() => {
8
10
  const refs = Array.isArray(targetRefs) ? targetRefs : [targetRefs]
@@ -13,6 +15,8 @@ export function useResizeObserver (targetRefs, callback) {
13
15
  }
14
16
 
15
17
  const observer = new window.ResizeObserver(entries => {
18
+ const changedEntries = []
19
+
16
20
  for (const entry of entries) {
17
21
  const { width, height } = entry.contentRect
18
22
  const prev = prevSizes.current.get(entry.target) || {}
@@ -22,8 +26,17 @@ export function useResizeObserver (targetRefs, callback) {
22
26
  }
23
27
 
24
28
  prevSizes.current.set(entry.target, { width, height })
29
+ changedEntries.push(entry)
30
+ }
25
31
 
26
- callback(entry)
32
+ if (changedEntries.length) {
33
+ // Batch all entries into a single RAF — prevents multiple simultaneous
34
+ // resize events (e.g. panel open) from queueing separate callbacks and
35
+ // causing a ResizeObserver loop under synchronous renderers like preact.
36
+ cancelAnimationFrame(frameRef.current)
37
+ frameRef.current = requestAnimationFrame(() => {
38
+ for (const entry of changedEntries) { callbackRef.current(entry) }
39
+ })
27
40
  }
28
41
  })
29
42
 
@@ -35,7 +48,7 @@ export function useResizeObserver (targetRefs, callback) {
35
48
  cancelAnimationFrame(frameRef.current)
36
49
  }
37
50
  }
38
- }, [targetRefs, callback])
51
+ }, [targetRefs]) // callback intentionally omitted — always read from callbackRef
39
52
 
40
53
  return { frameRef }
41
54
  }
@@ -26,6 +26,7 @@ describe('useResizeObserver', () => {
26
26
  this.trigger = (entries) => cb(entries) // helper to simulate resize
27
27
  })
28
28
 
29
+ jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 0 })
29
30
  jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
30
31
  })
31
32
 
@@ -7,6 +7,7 @@ import { useApp } from '../store/appContext.js'
7
7
  import { useConfig } from '../store/configContext.js'
8
8
  import { useEvaluateProp } from '../hooks/useEvaluateProp.js'
9
9
  import { useButtonStateEvaluator } from '../hooks/useButtonStateEvaluator.js'
10
+ import { useContinueEnabledEvaluator } from '../hooks/useContinueEnabledEvaluator.js'
10
11
 
11
12
  // Create a component for each plugin to handle its hooks properly
12
13
  const PluginInit = ({ plugin, mode }) => {
@@ -55,6 +56,7 @@ export const PluginInits = () => {
55
56
  // Evaluate reactive button states globally
56
57
  const evaluateProp = useEvaluateProp()
57
58
  useButtonStateEvaluator(evaluateProp)
59
+ useContinueEnabledEvaluator()
58
60
 
59
61
  return (
60
62
  <>
@@ -116,6 +116,24 @@ function resolveGroupOrder (group) {
116
116
  return group.order ?? 0
117
117
  }
118
118
 
119
+ function applySlotExclusivity (matching, appState) {
120
+ let exclusivePluginId = null
121
+
122
+ for (const [id, config] of matching) {
123
+ if (config.exclusiveSlot && !appState.hiddenButtons.has(id) && config.pluginId) {
124
+ if (exclusivePluginId !== null && exclusivePluginId !== config.pluginId) {
125
+ logger.warn(`Slot exclusivity conflict: plugins [${exclusivePluginId}, ${config.pluginId}] are both claiming exclusive slot ownership. Showing all buttons.`)
126
+ return matching
127
+ }
128
+ exclusivePluginId = config.pluginId
129
+ }
130
+ }
131
+
132
+ if (exclusivePluginId === null) { return matching }
133
+
134
+ return matching.filter(([_, config]) => config.pluginId === exclusivePluginId)
135
+ }
136
+
119
137
  function renderButton ({ btn, appState, appConfig, evaluateProp }) {
120
138
  const [buttonId, config] = btn
121
139
  const bpConfig = config[appState.breakpoint] ?? {}
@@ -148,7 +166,8 @@ function renderButton ({ btn, appState, appConfig, evaluateProp }) {
148
166
  function mapButtons ({ slot, appState, appConfig, evaluateProp }) {
149
167
  const { buttonConfig, breakpoint } = appState
150
168
 
151
- const matching = getMatchingButtons({ appState, appConfig, buttonConfig, slot, evaluateProp })
169
+ const raw = getMatchingButtons({ appState, appConfig, buttonConfig, slot, evaluateProp })
170
+ const matching = applySlotExclusivity(raw, appState)
152
171
 
153
172
  if (!matching.length) {
154
173
  return []
@@ -243,6 +262,7 @@ function mapButtons ({ slot, appState, appConfig, evaluateProp }) {
243
262
  export {
244
263
  mapButtons,
245
264
  getMatchingButtons,
265
+ applySlotExclusivity,
246
266
  renderButton,
247
267
  resolveGroupName,
248
268
  resolveGroupLabel,
@@ -1,7 +1,9 @@
1
1
  import React from 'react'
2
- import { mapButtons, getMatchingButtons, renderButton, resolveGroupName, resolveGroupLabel, resolveGroupOrder } from './mapButtons.js'
2
+ import { mapButtons, getMatchingButtons, applySlotExclusivity, renderButton, resolveGroupName, resolveGroupLabel, resolveGroupOrder } from './mapButtons.js'
3
+ import { logger } from '../../services/logger.js'
3
4
  import { getPanelConfig } from '../registry/panelRegistry.js'
4
5
 
6
+ jest.mock('../../services/logger.js', () => ({ logger: { warn: jest.fn() } }))
5
7
  jest.mock('../registry/buttonRegistry.js')
6
8
  jest.mock('../registry/panelRegistry.js')
7
9
  jest.mock('../components/MapButton/MapButton.jsx', () => ({
@@ -350,6 +352,49 @@ describe('mapButtons module', () => {
350
352
  expect(map()[0].order).toBe(0)
351
353
  })
352
354
 
355
+ it('filters to exclusive plugin when one plugin has a visible exclusiveSlot button', () => {
356
+ appState.buttonConfig = {
357
+ drawCancel: { ...baseBtn, pluginId: 'draw', exclusiveSlot: true },
358
+ journeyBack: { ...baseBtn }
359
+ }
360
+ const matching = [['drawCancel', appState.buttonConfig.drawCancel], ['journeyBack', appState.buttonConfig.journeyBack]]
361
+ const result = applySlotExclusivity(matching, appState)
362
+ expect(result).toHaveLength(1)
363
+ expect(result[0][0]).toBe('drawCancel')
364
+ })
365
+
366
+ it('returns all buttons when no exclusiveSlot buttons are visible', () => {
367
+ appState.buttonConfig = {
368
+ drawCancel: { ...baseBtn, pluginId: 'draw', exclusiveSlot: true },
369
+ journeyBack: { ...baseBtn }
370
+ }
371
+ appState.hiddenButtons = new Set(['drawCancel'])
372
+ const matching = [['drawCancel', appState.buttonConfig.drawCancel], ['journeyBack', appState.buttonConfig.journeyBack]]
373
+ const result = applySlotExclusivity(matching, appState)
374
+ expect(result).toHaveLength(2)
375
+ })
376
+
377
+ it('returns all buttons when no buttons have exclusiveSlot', () => {
378
+ const matching = [['b1', { ...baseBtn }], ['b2', { ...baseBtn }]]
379
+ expect(applySlotExclusivity(matching, appState)).toHaveLength(2)
380
+ })
381
+
382
+ it('ignores exclusiveSlot on buttons without a pluginId', () => {
383
+ const matching = [['hostBtn', { ...baseBtn, exclusiveSlot: true }]]
384
+ expect(applySlotExclusivity(matching, appState)).toHaveLength(1)
385
+ })
386
+
387
+ it('warns and returns all when multiple plugins claim exclusivity', () => {
388
+ const matching = [
389
+ ['drawCancel', { ...baseBtn, pluginId: 'draw', exclusiveSlot: true }],
390
+ ['otherBtn', { ...baseBtn, pluginId: 'other', exclusiveSlot: true }]
391
+ ]
392
+ const result = applySlotExclusivity(matching, appState)
393
+ expect(result).toHaveLength(2)
394
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('draw'))
395
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('other'))
396
+ })
397
+
353
398
  it('excludes menu items from slot rendering even when they have a matching slot', () => {
354
399
  appState.buttonConfig = ({
355
400
  parent: baseBtn,
@@ -40,7 +40,7 @@ export const initialState = (config) => {
40
40
  mode: mode || null,
41
41
  previousMode: null,
42
42
  safeZoneInset: null,
43
- disabledButtons: new Set(),
43
+ disabledButtons: config.backAndContinue?.continueLabel ? new Set(['journeyContinue']) : new Set(),
44
44
  hiddenButtons: new Set(),
45
45
  pressedButtons: new Set(),
46
46
  expandedButtons: new Set(),
@@ -70,6 +70,43 @@ describe('initialState', () => {
70
70
  expect(result.prefersReducedMotion).toBe(false)
71
71
  })
72
72
 
73
+ test('pre-disables journeyContinue when backAndContinue has a continueLabel', () => {
74
+ mockMedia()
75
+ mockPanels({})
76
+ mockFullscreen(false)
77
+
78
+ const config = {
79
+ behaviour: 'buttonFirst',
80
+ initialBreakpoint: 'md',
81
+ initialInterfaceType: 'desktop',
82
+ appColorScheme: 'light',
83
+ autoColorScheme: false,
84
+ backAndContinue: { continueLabel: 'Continue' },
85
+ ...createMockRegistries()
86
+ }
87
+
88
+ const result = initialState(config)
89
+ expect(result.disabledButtons.has('journeyContinue')).toBe(true)
90
+ })
91
+
92
+ test('does not pre-disable journeyContinue when backAndContinue is null', () => {
93
+ mockMedia()
94
+ mockPanels({})
95
+ mockFullscreen(false)
96
+
97
+ const config = {
98
+ behaviour: 'buttonFirst',
99
+ initialBreakpoint: 'md',
100
+ initialInterfaceType: 'desktop',
101
+ appColorScheme: 'light',
102
+ autoColorScheme: false,
103
+ ...createMockRegistries()
104
+ }
105
+
106
+ const result = initialState(config)
107
+ expect(result.disabledButtons.has('journeyContinue')).toBe(false)
108
+ })
109
+
73
110
  test('defaults mode to null when missing', () => {
74
111
  mockMedia({ prefersReducedMotion: false })
75
112
  mockPanels({})
@@ -225,8 +225,8 @@ export default class InteractiveMap {
225
225
  }
226
226
  })
227
227
 
228
- updateDOMState(this)
229
- this.eventBus.emit(events.APP_OPENED, { statePreserved: false })
228
+ const { isFullscreen } = updateDOMState(this)
229
+ this.eventBus.emit(events.APP_OPENED, { statePreserved: false, isFullscreen })
230
230
  } catch (err) {
231
231
  this._openButton?.removeAttribute('style')
232
232
  renderError(this.rootEl, this.config.genericErrorText)
@@ -304,9 +304,9 @@ export default class InteractiveMap {
304
304
  this._openButton.style.display = 'none'
305
305
  }
306
306
 
307
- updateDOMState(this)
307
+ const { isFullscreen } = updateDOMState(this)
308
308
 
309
- this.eventBus.emit(events.APP_OPENED, { statePreserved: true })
309
+ this.eventBus.emit(events.APP_OPENED, { statePreserved: true, isFullscreen })
310
310
  }
311
311
 
312
312
  /**
@@ -415,6 +415,16 @@ export default class InteractiveMap {
415
415
  this.eventBus.emit(events.APP_TOGGLE_BUTTON_STATE, { id, prop, value })
416
416
  }
417
417
 
418
+ /**
419
+ * Enable or disable the journey continue button.
420
+ * Only has effect when backAndContinue is configured.
421
+ *
422
+ * @param {boolean} enabled
423
+ */
424
+ setContinueEnabled (enabled) {
425
+ this.toggleButtonState('journeyContinue', 'disabled', !enabled)
426
+ }
427
+
418
428
  /**
419
429
  * Add a panel to the UI.
420
430
  *
@@ -23,7 +23,7 @@ jest.mock('./behaviourController.js', () => ({
23
23
  setupBehavior: jest.fn(),
24
24
  shouldLoadComponent: jest.fn(() => true)
25
25
  }))
26
- jest.mock('./domStateManager.js', () => ({ updateDOMState: jest.fn(), removeLoadingState: jest.fn() }))
26
+ jest.mock('./domStateManager.js', () => ({ updateDOMState: jest.fn().mockReturnValue({ isFullscreen: false }), removeLoadingState: jest.fn() }))
27
27
  jest.mock('./renderError.js', () => ({ renderError: jest.fn() }))
28
28
  jest.mock('../config/mergeConfig.js', () => ({ mergeConfig: jest.fn(cfg => cfg) }))
29
29
  const mockBreakpointDetector = {
@@ -183,7 +183,7 @@ describe('InteractiveMap — loadApp', () => {
183
183
  await map.loadApp()
184
184
  expect(map.on).toBe(originalOn)
185
185
  expect(typeof map.someMethod).toBe('function')
186
- expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: false })
186
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: false, isFullscreen: false })
187
187
  })
188
188
  })
189
189
 
@@ -436,7 +436,7 @@ describe('InteractiveMap — hideApp and showApp', () => {
436
436
  expect(map.rootEl.style.display).toBe('')
437
437
  expect(mockButtonInstance.style.display).toBe('none')
438
438
  expect(updateDOMState).toHaveBeenCalledWith(map)
439
- expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: true })
439
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: true, isFullscreen: false })
440
440
  })
441
441
 
442
442
  it('showApp: works without _openButton', () => {
@@ -563,6 +563,14 @@ describe('InteractiveMap — Public API Methods', () => {
563
563
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'btn-1', prop: 'disabled', value: true })
564
564
  })
565
565
 
566
+ it('setContinueEnabled enables and disables the journey continue button', () => {
567
+ map.setContinueEnabled(true)
568
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'journeyContinue', prop: 'disabled', value: false })
569
+
570
+ map.setContinueEnabled(false)
571
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:togglebuttonstate', { id: 'journeyContinue', prop: 'disabled', value: true })
572
+ })
573
+
566
574
  it('fitToBounds and setView emit correct events', () => {
567
575
  const bbox = [-0.489, 51.28, 0.236, 51.686]
568
576
  const center = [-0.1276, 51.5074]
@@ -1,6 +1,7 @@
1
1
  import { getQueryParam } from '../utils/queryString.js'
2
2
  import { isHybridFullscreen } from '../utils/getIsFullscreen.js'
3
3
  import { updateDOMState } from './domStateManager.js'
4
+ import { EVENTS } from '../config/events.js'
4
5
 
5
6
  // -----------------------------------------------------------------------------
6
7
  // Public API
@@ -62,7 +63,8 @@ function setupBehavior (mapInstance) {
62
63
  mapInstance.loadApp()
63
64
  } else {
64
65
  // Map is showing - update DOM state for fullscreen/inline transition
65
- updateDOMState(mapInstance)
66
+ const { isFullscreen } = updateDOMState(mapInstance)
67
+ mapInstance.eventBus.emit(EVENTS.APP_FULLSCREEN_CHANGE, { isFullscreen })
66
68
  }
67
69
  } else if (mapInstance._root) {
68
70
  mapInstance.hideApp()
@@ -7,7 +7,7 @@ import * as queryString from '../utils/queryString.js'
7
7
  import { updateDOMState } from './domStateManager.js'
8
8
 
9
9
  jest.mock('../utils/queryString.js')
10
- jest.mock('./domStateManager.js', () => ({ updateDOMState: jest.fn() }))
10
+ jest.mock('./domStateManager.js', () => ({ updateDOMState: jest.fn().mockReturnValue({ isFullscreen: false }) }))
11
11
 
12
12
  describe('shouldLoadComponent', () => {
13
13
  beforeEach(() => {
@@ -67,7 +67,8 @@ describe('setupBehavior', () => {
67
67
  loadApp: jest.fn(),
68
68
  removeApp: jest.fn(),
69
69
  hideApp: jest.fn(),
70
- showApp: jest.fn()
70
+ showApp: jest.fn(),
71
+ eventBus: { emit: jest.fn() }
71
72
  }
72
73
  // Default: viewport is wide
73
74
  window.matchMedia = jest.fn().mockImplementation(() => ({
@@ -187,14 +188,16 @@ describe('setupBehavior', () => {
187
188
  expect(mockMapInstance.showApp).not.toHaveBeenCalled()
188
189
  })
189
190
 
190
- it('calls updateDOMState when map is showing and should load', () => {
191
+ it('calls updateDOMState and emits APP_FULLSCREEN_CHANGE when map is showing and should load', () => {
191
192
  mockMapInstance._isHidden = false
192
193
  mockMapInstance._root = {} // has root
193
194
  queryString.getQueryParam.mockReturnValue(null)
195
+ updateDOMState.mockReturnValue({ isFullscreen: false })
194
196
 
195
197
  handleChange()
196
198
 
197
199
  expect(updateDOMState).toHaveBeenCalledWith(mockMapInstance)
200
+ expect(mockMapInstance.eventBus.emit).toHaveBeenCalledWith('app:fullscreenchange', { isFullscreen: false })
198
201
  expect(mockMapInstance.loadApp).not.toHaveBeenCalled()
199
202
  expect(mockMapInstance.showApp).not.toHaveBeenCalled()
200
203
  })
@@ -70,6 +70,8 @@ function updateDOMState (mapInstance, { isFullscreen: isFullscreenOverride } = {
70
70
  ? 'auto'
71
71
  : containerHeight
72
72
  rootEl.style.height = isFullscreen ? '100%' : height
73
+
74
+ return { isFullscreen }
73
75
  }
74
76
 
75
77
  /**