@defra/interactive-map 0.0.17-alpha → 0.0.19-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 (185) hide show
  1. package/assets/css/docusaurus.css +58 -34
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/context.md +53 -7
  8. package/docs/api/map-style-config.md +41 -2
  9. package/docs/api/marker-config.md +53 -11
  10. package/docs/api/panel-definition.md +16 -0
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +50 -23
  14. package/docs/assets/basic-map.jpg +0 -0
  15. package/docs/assets/button-first.jpg +0 -0
  16. package/docs/assets/maker-panel.jpg +0 -0
  17. package/docs/examples/add-marker-with-panel.mdx +59 -0
  18. package/docs/examples/basic-map.mdx +24 -0
  19. package/docs/examples/button-map.mdx +24 -0
  20. package/docs/examples/index.mdx +49 -0
  21. package/docs/index.mdx +1 -1
  22. package/docs/plugins/datasets.md +105 -9
  23. package/docs/plugins/interact.md +100 -44
  24. package/docs/plugins/search.md +15 -3
  25. package/docs/plugins.md +1 -1
  26. package/docusaurus.config.cjs +9 -1
  27. package/package.json +1 -1
  28. package/plugins/beta/datasets/dist/css/index.css +32 -14
  29. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  31. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  32. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  33. package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
  34. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
  35. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
  36. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
  37. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  38. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  39. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  40. package/plugins/beta/datasets/src/api/setData.js +4 -2
  41. package/plugins/beta/datasets/src/api/setStyle.js +2 -2
  42. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  43. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  44. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  45. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  46. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  47. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  48. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  49. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  50. package/plugins/beta/datasets/src/datasets.js +13 -4
  51. package/plugins/beta/datasets/src/defaults.js +4 -2
  52. package/plugins/beta/datasets/src/index.js +2 -1
  53. package/plugins/beta/datasets/src/manifest.js +1 -1
  54. package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
  55. package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
  56. package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
  57. package/plugins/beta/datasets/src/reducer.js +6 -0
  58. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  59. package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
  60. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  61. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  62. package/plugins/beta/draw-ml/dist/css/index.css +3 -0
  63. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  64. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  65. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  66. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  67. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  68. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  69. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  70. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  71. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  72. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  73. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  74. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  75. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  76. package/plugins/interact/dist/umd/index.js +1 -1
  77. package/plugins/interact/src/InteractInit.jsx +19 -8
  78. package/plugins/interact/src/InteractInit.test.js +26 -6
  79. package/plugins/interact/src/api/clear.js +1 -1
  80. package/plugins/interact/src/api/enable.test.js +7 -7
  81. package/plugins/interact/src/api/selectMarker.js +14 -0
  82. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  83. package/plugins/interact/src/api/unselectMarker.js +14 -0
  84. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  85. package/plugins/interact/src/defaults.js +4 -6
  86. package/plugins/interact/src/events.js +27 -36
  87. package/plugins/interact/src/events.test.js +119 -90
  88. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  89. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  90. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  91. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  92. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  93. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  94. package/plugins/interact/src/manifest.js +10 -2
  95. package/plugins/interact/src/reducer.js +59 -5
  96. package/plugins/interact/src/reducer.test.js +100 -12
  97. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  98. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  99. package/plugins/interact/src/utils/featureQueries.js +11 -6
  100. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  101. package/plugins/interact/src/utils/interactionModes.js +12 -0
  102. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  103. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  104. package/plugins/search/src/Search.jsx +3 -1
  105. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  106. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  107. package/plugins/search/src/events/formHandlers.js +3 -3
  108. package/plugins/search/src/events/formHandlers.test.js +1 -1
  109. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  110. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  111. package/plugins/search/src/utils/updateMap.js +3 -3
  112. package/plugins/search/src/utils/updateMap.test.js +3 -3
  113. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  114. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  115. package/providers/maplibre/dist/umd/index.js +1 -1
  116. package/providers/maplibre/src/appEvents.js +7 -0
  117. package/providers/maplibre/src/appEvents.test.js +18 -4
  118. package/providers/maplibre/src/maplibreProvider.js +52 -0
  119. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  120. package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
  121. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
  122. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  123. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  124. package/providers/maplibre/src/utils/patternImages.js +70 -0
  125. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  126. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  127. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  128. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  129. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  130. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  131. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  132. package/src/App/components/Markers/Markers.jsx +122 -27
  133. package/src/App/components/Markers/Markers.module.scss +0 -10
  134. package/src/App/components/Markers/Markers.test.jsx +246 -0
  135. package/src/App/components/Panel/Panel.jsx +6 -6
  136. package/src/App/components/Panel/Panel.test.jsx +37 -0
  137. package/src/App/components/Viewport/Viewport.jsx +5 -15
  138. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  139. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  140. package/src/App/hooks/useInterfaceAPI.js +7 -7
  141. package/src/App/hooks/useInterfaceAPI.test.js +162 -0
  142. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  143. package/src/App/hooks/useMarkersAPI.js +2 -5
  144. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  145. package/src/App/layout/Layout.jsx +3 -3
  146. package/src/App/layout/Layout.test.jsx +4 -2
  147. package/src/App/layout/layout.module.scss +1 -8
  148. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  149. package/src/App/renderer/mapPanels.js +2 -1
  150. package/src/App/store/ServiceProvider.jsx +7 -5
  151. package/src/App/store/appActionsMap.js +4 -4
  152. package/src/App/store/appActionsMap.test.js +10 -0
  153. package/src/App/store/mapActionsMap.js +4 -6
  154. package/src/App/store/mapActionsMap.test.js +3 -2
  155. package/src/App/store/mapReducer.js +2 -1
  156. package/src/InteractiveMap/InteractiveMap.js +59 -11
  157. package/src/InteractiveMap/InteractiveMap.test.js +126 -4
  158. package/src/InteractiveMap/domStateManager.js +18 -6
  159. package/src/InteractiveMap/domStateManager.test.js +21 -0
  160. package/src/InteractiveMap/historyManager.js +28 -16
  161. package/src/InteractiveMap/historyManager.test.js +17 -0
  162. package/src/config/appConfig.js +2 -7
  163. package/src/config/appConfig.test.js +4 -15
  164. package/src/config/defaults.js +2 -3
  165. package/src/config/events.js +20 -21
  166. package/src/config/mapTheme.js +56 -0
  167. package/src/config/patternConfig.js +16 -0
  168. package/src/config/symbolConfig.js +80 -0
  169. package/src/scss/settings/_colors.scss +0 -9
  170. package/src/services/closeApp.js +1 -10
  171. package/src/services/closeApp.test.js +3 -43
  172. package/src/services/patternRegistry.js +40 -0
  173. package/src/services/patternRegistry.test.js +48 -0
  174. package/src/services/symbolRegistry.js +113 -0
  175. package/src/services/symbolRegistry.test.js +262 -0
  176. package/src/types.js +99 -12
  177. package/src/utils/mapStateSync.js +48 -10
  178. package/src/utils/mapStateSync.test.js +29 -9
  179. package/src/utils/patternUtils.js +94 -0
  180. package/src/utils/patternUtils.test.js +160 -0
  181. package/src/utils/symbolUtils.js +85 -0
  182. package/src/utils/symbolUtils.test.js +156 -0
  183. package/docs/examples.mdx +0 -70
  184. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
  185. package/plugins/beta/datasets/src/styles/patterns.js +0 -157
@@ -19,7 +19,7 @@ describe('projectCoords', () => {
19
19
  const mockProvider = { mapToScreen: jest.fn(() => ({ x: 100, y: 200 })) }
20
20
 
21
21
  it('returns scaled coordinates when ready', () => {
22
- expect(projectCoords({ lat: 1, lng: 1 }, mockProvider, 'medium', true)).toEqual({ x: 200, y: 381 })
22
+ expect(projectCoords({ lat: 1, lng: 1 }, mockProvider, 'medium', true)).toEqual({ x: 200, y: 400 })
23
23
  })
24
24
 
25
25
  it('returns zero when not ready or no provider', () => {
@@ -102,7 +102,7 @@ describe('useMarkers', () => {
102
102
 
103
103
  const renderCallback = mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')[1]
104
104
  act(() => renderCallback())
105
- expect(mockElement.style.transform).toBe('translate(200px, 381px)')
105
+ expect(mockElement.style.transform).toBe('translate(200px, 400px)')
106
106
  })
107
107
 
108
108
  it('skips map:render when not ready or no provider (line 60)', () => {
@@ -126,7 +126,7 @@ describe('useMarkers', () => {
126
126
  isMapReady: true
127
127
  })
128
128
  rerender()
129
- expect(mockElement.style.transform).toBe('translate(300px, 581px)')
129
+ expect(mockElement.style.transform).toBe('translate(300px, 600px)')
130
130
  })
131
131
 
132
132
  it('handles app:addmarker safely', () => {
@@ -137,7 +137,7 @@ describe('useMarkers', () => {
137
137
  act(() => handleAddMarker(addPayload))
138
138
  expect(mockDispatch).toHaveBeenCalledWith({
139
139
  type: 'UPSERT_LOCATION_MARKER',
140
- payload: { id: 'm1', coords: { lat: 1, lng: 1 }, label: 'Test', x: 200, y: 381, isVisible: true }
140
+ payload: { id: 'm1', coords: { lat: 1, lng: 1 }, label: 'Test', x: 200, y: 400, isVisible: true }
141
141
  })
142
142
  })
143
143
 
@@ -10,6 +10,7 @@ import { Attributions } from '../components/Attributions/Attributions'
10
10
  import { layoutSlots } from '../renderer/slots'
11
11
  import { SlotRenderer } from '../renderer/SlotRenderer'
12
12
  import { HtmlElementHost } from '../renderer/HtmlElementHost'
13
+ import { getMapThemeVars } from '../../config/mapTheme.js'
13
14
 
14
15
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
15
16
  // sonarjs/disable-next-line function-name
@@ -30,13 +31,12 @@ export const Layout = () => {
30
31
  `im-o-app--${interfaceType}`,
31
32
  `im-o-app--${isFullscreen ? 'fullscreen' : 'inline'}`,
32
33
  `im-o-app--${mapStyle?.appColorScheme || preferredColorScheme}-app`,
33
- `im-o-app--${mapStyle?.mapColorScheme || 'light'}-map`,
34
34
  hasExclusiveControl && 'im-o-app--exclusive-control'
35
35
  ].filter(Boolean).join(' ')}
36
- style={{ backgroundColor: mapStyle?.backgroundColor || undefined }}
36
+ style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }}
37
37
  ref={layoutRefs.appContainerRef}
38
38
  >
39
- <Viewport keyboardHintPortalRef={layoutRefs.topRef} />
39
+ <Viewport />
40
40
  <div className={`im-o-app__overlay${isLayoutReady ? '' : ' im-o-app__overlay--not-ready'}`}>
41
41
  <div className='im-o-app__side' ref={layoutRefs.sideRef}>
42
42
  <SlotRenderer slot={layoutSlots.SIDE} />
@@ -72,9 +72,11 @@ describe('Layout', () => {
72
72
  expect(root.className).toContain('im-o-app--map')
73
73
  expect(root.className).toContain('im-o-app--inline')
74
74
  expect(root.className).toContain('im-o-app--light-app')
75
- expect(root.className).toContain('im-o-app--dark-map')
76
75
  expect(root.className).toContain('im-o-app--exclusive-control')
77
76
  expect(root.style.backgroundColor).toBe('pink')
77
+ expect(root.style.getPropertyValue('--map-overlay-halo-color')).toBe('#0b0c0c')
78
+ expect(root.style.getPropertyValue('--map-overlay-selected-color')).toBe('#ffffff')
79
+ expect(root.style.getPropertyValue('--map-overlay-foreground-color')).toBe('#ffffff')
78
80
 
79
81
  const overlay = root.querySelector('.im-o-app__overlay')
80
82
  expect(overlay.className).not.toContain('not-ready')
@@ -123,7 +125,7 @@ describe('Layout', () => {
123
125
 
124
126
  const root = document.getElementById('myApp-im-app')
125
127
  expect(root.className).toContain('im-o-app--dark-app')
126
- expect(root.className).toContain('im-o-app--light-map') // fallback
128
+ expect(root.className).not.toContain('im-o-app--light-map')
127
129
  expect(root.style.backgroundColor).toBe('')
128
130
  expect(root.className).not.toContain('exclusive-control')
129
131
  })
@@ -436,16 +436,9 @@
436
436
  // Inline border
437
437
  .im-o-app--inline {
438
438
  border: var(--app-border-width) solid var(--app-border-color);
439
+ box-sizing: border-box;
439
440
  }
440
441
 
441
- // Hide containers when keyboard hint is visible
442
- .im-o-app__main--keyboard-hint-visible {
443
- .im-o-app__top-col,
444
- .im-o-app__right,
445
- .im-o-app__right-bottom {
446
- opacity: 0;
447
- }
448
- }
449
442
 
450
443
  // Avoid refresh jump if layout clacs are not ready
451
444
  .im-o-app__overlay--not-ready {
@@ -39,11 +39,14 @@ export const getSlotRef = (slot, layoutRefs) => {
39
39
  * (e.g. the banner slot swaps DOM nodes between mobile and desktop).
40
40
  */
41
41
  export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint) => {
42
+ const layoutRefsRef = useRef(layoutRefs)
43
+ layoutRefsRef.current = layoutRefs
44
+
42
45
  useLayoutEffect(() => {
43
46
  const wrapper = wrapperRef.current
44
47
 
45
48
  if (isVisible) {
46
- const slotRef = getSlotRef(targetSlot, layoutRefs)
49
+ const slotRef = getSlotRef(targetSlot, layoutRefsRef.current)
47
50
  if (slotRef?.current) {
48
51
  const backdrop = slotRef.current.querySelector(':scope > .im-o-app__modal-backdrop')
49
52
  if (backdrop) {
@@ -54,8 +57,8 @@ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs,
54
57
  wrapper.style.display = ''
55
58
  }
56
59
  } else {
57
- if (wrapper.parentElement === layoutRefs.modalRef?.current) {
58
- layoutRefs.appContainerRef?.current?.appendChild(wrapper)
60
+ if (wrapper.parentElement === layoutRefsRef.current.modalRef?.current) {
61
+ layoutRefsRef.current.appContainerRef?.current?.appendChild(wrapper)
59
62
  }
60
63
  wrapper.style.display = 'none'
61
64
  }
@@ -63,7 +66,7 @@ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs,
63
66
  return () => {
64
67
  wrapper.style.display = 'none'
65
68
  }
66
- }, [isVisible, targetSlot, layoutRefs, breakpoint, wrapperRef])
69
+ }, [isVisible, targetSlot, breakpoint, wrapperRef])
67
70
  }
68
71
 
69
72
  /**
@@ -71,7 +74,7 @@ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs,
71
74
  * The Panel component stays mounted for the lifetime of the registration.
72
75
  * DOM projection moves it between slots; CSS hides it when closed.
73
76
  */
74
- const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, allowedModalPanelId, appState }) => {
77
+ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen, allowedModalPanelId, appState }) => {
75
78
  const panelRootRef = useRef(null)
76
79
  const { breakpoint, mode, isFullscreen, layoutRefs } = appState
77
80
 
@@ -108,6 +111,7 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, allowedModal
108
111
  panelId={panelId}
109
112
  panelConfig={config}
110
113
  props={openPanelProps}
114
+ focusOnOpen={focusOnOpen}
111
115
  html={config.html}
112
116
  label={config.label}
113
117
  isOpen={isOpen}
@@ -186,6 +190,7 @@ export const HtmlElementHost = () => {
186
190
  config={config}
187
191
  isOpen={!!openPanels[panelId]}
188
192
  openPanelProps={openPanels[panelId]?.props}
193
+ focusOnOpen={openPanels[panelId]?.focusOnOpen}
189
194
  allowedModalPanelId={allowedModalPanelId}
190
195
  appState={appState}
191
196
  />
@@ -46,7 +46,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
46
46
  })
47
47
  const allowedModalPanelId = modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null
48
48
 
49
- return openPanelEntries.map(([panelId, { props }]) => {
49
+ return openPanelEntries.map(([panelId, { props, focusOnOpen }]) => {
50
50
  const config = panelConfig[panelId]
51
51
  if (!config) {
52
52
  return null
@@ -91,6 +91,7 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
91
91
  panelId={panelId}
92
92
  panelConfig={config}
93
93
  props={props}
94
+ focusOnOpen={focusOnOpen}
94
95
  WrappedChild={WrappedChild}
95
96
  label={evaluateProp(config.label, pluginId)}
96
97
  html={pluginId ? evaluateProp(config.html, pluginId) : config.html}
@@ -1,27 +1,29 @@
1
1
  // src/App/store/ServiceProvider.jsx
2
2
  import React, { createContext, useMemo, useRef } from 'react'
3
- import { EVENTS } from '../../config/events.js'
4
3
  import { createAnnouncer } from '../../services/announcer.js'
5
4
  import { reverseGeocode } from '../../services/reverseGeocode.js'
6
5
  import { useConfig } from '../store/configContext.js'
7
6
  import { closeApp } from '../../services/closeApp.js'
8
- import { logger } from '../../services/logger.js'
7
+ import { symbolRegistry } from '../../services/symbolRegistry.js'
8
+ import { patternRegistry } from '../../services/patternRegistry.js'
9
9
 
10
10
  export const ServiceContext = createContext(null)
11
11
 
12
12
  export const ServiceProvider = ({ eventBus, children }) => {
13
- const { id, handleExitClick } = useConfig()
13
+ const { id, handleExitClick, symbolDefaults: constructorSymbolDefaults } = useConfig()
14
14
  const mapStatusRef = useRef(null)
15
15
  const announce = useMemo(() => createAnnouncer(mapStatusRef), [])
16
16
 
17
+ symbolRegistry.setDefaults(constructorSymbolDefaults || {})
18
+
17
19
  const services = useMemo(() => ({
18
20
  announce,
19
21
  reverseGeocode: (zoom, center) => reverseGeocode(zoom, center),
20
- events: EVENTS,
21
22
  eventBus,
22
23
  mapStatusRef,
23
24
  closeApp: () => closeApp(id, handleExitClick, eventBus),
24
- logger
25
+ symbolRegistry,
26
+ patternRegistry
25
27
  }), [announce])
26
28
 
27
29
  return (
@@ -7,7 +7,7 @@ import { registerPanel as registerPanelFn, addPanel as addPanelFn, removePanel a
7
7
  import { registerControl as registerControlFn, addControl as addControlFn } from '../registry/controlRegistry.js'
8
8
 
9
9
  // Interal helper
10
- function buildOpenPanels (state, panelId, breakpoint, props) {
10
+ function buildOpenPanels (state, panelId, breakpoint, props, focusOnOpen) {
11
11
  const panelConfig = state.panelConfig || state.panelRegistry.getPanelConfig()
12
12
  const bpConfig = panelConfig[panelId]?.[breakpoint]
13
13
  const isExclusiveNonModal = !!bpConfig.exclusive && !bpConfig.modal
@@ -23,7 +23,7 @@ function buildOpenPanels (state, panelId, breakpoint, props) {
23
23
  return {
24
24
  ...(isExclusiveNonModal ? {} : filteredPanels),
25
25
  ...(isModal ? state.openPanels : {}),
26
- [panelId]: { props }
26
+ [panelId]: { props, ...(focusOnOpen && { focusOnOpen: true }) }
27
27
  }
28
28
  }
29
29
 
@@ -109,12 +109,12 @@ const setInterfaceType = (state, payload) => {
109
109
  }
110
110
 
111
111
  const openPanel = (state, payload) => {
112
- const { panelId, props = {} } = payload
112
+ const { panelId, props = {}, focusOnOpen } = payload
113
113
 
114
114
  return {
115
115
  ...state,
116
116
  previousOpenPanels: state.openPanels,
117
- openPanels: buildOpenPanels(state, panelId, state.breakpoint, props)
117
+ openPanels: buildOpenPanels(state, panelId, state.breakpoint, props, focusOnOpen)
118
118
  }
119
119
  }
120
120
 
@@ -100,6 +100,16 @@ describe('actionsMap full coverage', () => {
100
100
  expect(result.openPanels.panel3?.props).toEqual({})
101
101
  })
102
102
 
103
+ test('OPEN_PANEL stores focusOnOpen when provided', () => {
104
+ const result = actionsMap.OPEN_PANEL(state, { panelId: 'panel2', focusOnOpen: true })
105
+ expect(result.openPanels.panel2?.focusOnOpen).toBe(true)
106
+ })
107
+
108
+ test('OPEN_PANEL omits focusOnOpen when not provided', () => {
109
+ const result = actionsMap.OPEN_PANEL(state, { panelId: 'panel2' })
110
+ expect(result.openPanels.panel2?.focusOnOpen).toBeUndefined()
111
+ })
112
+
103
113
  test('CLOSE_PANEL removes a panel', () => {
104
114
  const result = actionsMap.CLOSE_PANEL(state, 'panel1')
105
115
  expect(result.openPanels.panel1).toBeUndefined()
@@ -12,12 +12,10 @@ const setMapReady = (state) => ({
12
12
  isMapReady: true
13
13
  })
14
14
 
15
- const setMapStyle = (state, payload) => {
16
- return {
17
- ...state,
18
- mapStyle: payload
19
- }
20
- }
15
+ const setMapStyle = (state, payload) => ({
16
+ ...state,
17
+ mapStyle: payload
18
+ })
21
19
 
22
20
  const setMapSize = (state, payload) => {
23
21
  return {
@@ -46,8 +46,9 @@ describe('actionsMap', () => {
46
46
  })
47
47
 
48
48
  test('SET_MAP_STYLE sets mapStyle', () => {
49
- const result = actionsMap.SET_MAP_STYLE(state, 'satellite')
50
- expect(result.mapStyle).toBe('satellite')
49
+ const mapStyle = { id: 'satellite' }
50
+ const result = actionsMap.SET_MAP_STYLE(state, mapStyle)
51
+ expect(result.mapStyle).toBe(mapStyle)
51
52
  expect(result.otherProp).toBe(state.otherProp)
52
53
  })
53
54
 
@@ -17,10 +17,11 @@ export const initialState = (config) => {
17
17
  // Does a plugin handle map styles
18
18
  const pluginHandlesMapStyles = !!registeredPlugins?.find(plugin => plugin.config?.handlesMapStyle)
19
19
 
20
+ const initialMapStyle = pluginHandlesMapStyles ? null : mapStyle
20
21
  return {
21
22
  isMapReady: false,
22
23
  mapProvider: null,
23
- mapStyle: pluginHandlesMapStyles ? null : mapStyle,
24
+ mapStyle: initialMapStyle,
24
25
  mapSize,
25
26
  center,
26
27
  zoom,
@@ -103,7 +103,9 @@ export default class InteractiveMap {
103
103
  }
104
104
 
105
105
  _handleButtonClick (e) {
106
- history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
106
+ if (this.config.manageHistoryState) {
107
+ history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
108
+ }
107
109
  if (this._isHidden) {
108
110
  this.showApp()
109
111
  } else {
@@ -132,11 +134,20 @@ export default class InteractiveMap {
132
134
  this.removeApp()
133
135
  }
134
136
 
135
- const key = this.config.mapViewParamKey
136
- const href = location.href
137
- const newUrl = this._removeMapParamFromUrl(href, key)
137
+ if (!this.config.manageHistoryState) {
138
+ return
139
+ }
138
140
 
139
- history.replaceState(history.state, '', newUrl)
141
+ // If this history entry was pushed by the map's open button, go back so the
142
+ // ?mv= entry is preserved as a forward entry (browser forward re-opens the map).
143
+ // Otherwise (direct URL / bookmark), just strip the param in place.
144
+ if (history.state?.isBack) {
145
+ history.back()
146
+ } else {
147
+ const key = this.config.mapViewParamKey
148
+ const newUrl = this._removeMapParamFromUrl(location.href, key)
149
+ history.replaceState(history.state, '', newUrl)
150
+ }
140
151
  }
141
152
 
142
153
  /**
@@ -190,6 +201,7 @@ export default class InteractiveMap {
190
201
  })
191
202
 
192
203
  updateDOMState(this)
204
+ this.eventBus.emit(events.APP_OPENED, { statePreserved: false })
193
205
  } catch (err) {
194
206
  renderError(this.rootEl, this.config.genericErrorText)
195
207
  console.error(err)
@@ -213,8 +225,9 @@ export default class InteractiveMap {
213
225
  this._openButton.focus()
214
226
  }
215
227
 
216
- updateDOMState(this)
228
+ updateDOMState(this, { isFullscreen: false })
217
229
 
230
+ this.eventBus.emit(events.APP_CLOSED, { statePreserved: false })
218
231
  this.eventBus.emit(events.MAP_DESTROY, { mapId: this.id })
219
232
  }
220
233
 
@@ -243,10 +256,10 @@ export default class InteractiveMap {
243
256
  // Reset page title (remove prepended map title)
244
257
  const parts = document.title.split(': ')
245
258
  if (parts.length > 1) {
246
- document.title = parts[parts.length - 1]
259
+ document.title = parts.at(-1)
247
260
  }
248
261
 
249
- this.eventBus.emit(events.APP_HIDDEN)
262
+ this.eventBus.emit(events.APP_CLOSED, { statePreserved: true })
250
263
  }
251
264
 
252
265
  /**
@@ -265,7 +278,7 @@ export default class InteractiveMap {
265
278
 
266
279
  updateDOMState(this)
267
280
 
268
- this.eventBus.emit(events.APP_VISIBLE)
281
+ this.eventBus.emit(events.APP_OPENED, { statePreserved: true })
269
282
  }
270
283
 
271
284
  /**
@@ -365,6 +378,10 @@ export default class InteractiveMap {
365
378
  /**
366
379
  * Add a panel to the UI.
367
380
  *
381
+ * Focus is moved to the panel on open by default. Set `focus: false` in the
382
+ * config to suppress this — useful when adding panels on page load where
383
+ * stealing focus would be disruptive.
384
+ *
368
385
  * @param {string} id - Unique panel identifier.
369
386
  * @param {PanelDefinition} config - Panel configuration.
370
387
  */
@@ -384,10 +401,15 @@ export default class InteractiveMap {
384
401
  /**
385
402
  * Show a panel.
386
403
  *
404
+ * Focus is moved to the panel by default. Set `focus: false` in options to
405
+ * suppress this — useful when showing a panel and you want focus to remain on the button.
406
+ *
387
407
  * @param {string} id - Panel identifier to show.
408
+ * @param {object} [options]
409
+ * @param {boolean} [options.focus=true] - Whether to move focus to the panel.
388
410
  */
389
- showPanel (id) {
390
- this.eventBus.emit(events.APP_SHOW_PANEL, id)
411
+ showPanel (id, { focus = true } = {}) {
412
+ this.eventBus.emit(events.APP_SHOW_PANEL, { id, focus })
391
413
  }
392
414
 
393
415
  /**
@@ -426,4 +448,30 @@ export default class InteractiveMap {
426
448
  setView (opts) {
427
449
  this.eventBus.emit(events.MAP_SET_VIEW, opts)
428
450
  }
451
+
452
+ /**
453
+ * Programmatically open the map.
454
+ *
455
+ * Equivalent to the user clicking the open button. If the map has been hidden (e.g. in hybrid mode),
456
+ * it will be shown; otherwise the app will be loaded for the first time.
457
+ */
458
+ open () {
459
+ if (this._isHidden) {
460
+ this.showApp()
461
+ } else if (this._root) {
462
+ // App is already open — no-op
463
+ } else {
464
+ this.loadApp()
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Programmatically close the map.
470
+ *
471
+ * Triggers the same logic as the exit button. If `preserveStateOnClose` is true, the map is hidden
472
+ * but not destroyed; otherwise the app is removed entirely.
473
+ */
474
+ close () {
475
+ this._handleExitClick()
476
+ }
429
477
  }
@@ -117,7 +117,7 @@ describe('InteractiveMap Core Functionality', () => {
117
117
  })
118
118
 
119
119
  it('open button click calls _handleButtonClick / loadApp', async () => {
120
- const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
120
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: true, mapProvider: mapProviderMock })
121
121
  const loadSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
122
122
  const pushStateSpy = jest.spyOn(history, 'pushState').mockImplementation(() => {})
123
123
  const fakeEvent = { currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } }
@@ -195,7 +195,7 @@ describe('InteractiveMap Core Functionality', () => {
195
195
  expect(map.unmount).toHaveBeenCalled()
196
196
  expect(mockButtonInstance.removeAttribute).toHaveBeenCalledWith('style')
197
197
  expect(mockButtonInstance.focus).toHaveBeenCalled()
198
- expect(updateDOMState).toHaveBeenCalledWith(map)
198
+ expect(updateDOMState).toHaveBeenCalledWith(map, { isFullscreen: false })
199
199
  })
200
200
 
201
201
  it('skips unmount if _root is falsy or unmount is not a function', () => {
@@ -275,6 +275,7 @@ describe('InteractiveMap Core Functionality', () => {
275
275
  behaviour: 'buttonFirst',
276
276
  mapProvider: mapProviderMock,
277
277
  mapViewParamKey: 'mv',
278
+ manageHistoryState: true,
278
279
  preserveStateOnClose: false
279
280
  })
280
281
 
@@ -303,6 +304,7 @@ describe('InteractiveMap Core Functionality', () => {
303
304
  behaviour: 'buttonFirst',
304
305
  mapProvider: mapProviderMock,
305
306
  mapViewParamKey: 'mv',
307
+ manageHistoryState: true,
306
308
  preserveStateOnClose: true
307
309
  })
308
310
 
@@ -321,7 +323,7 @@ describe('InteractiveMap Core Functionality', () => {
321
323
  })
322
324
 
323
325
  it('_handleButtonClick calls showApp when map is hidden', async () => {
324
- const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
326
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: true, mapProvider: mapProviderMock })
325
327
  map._isHidden = true
326
328
  const showAppSpy = jest.spyOn(map, 'showApp').mockImplementation(() => {})
327
329
  const loadAppSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
@@ -339,6 +341,126 @@ describe('InteractiveMap Core Functionality', () => {
339
341
  pushStateSpy.mockRestore()
340
342
  })
341
343
 
344
+ it('_handleButtonClick skips pushState when manageHistoryState is false', async () => {
345
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: false, mapProvider: mapProviderMock })
346
+ expect(map.config.manageHistoryState).toBe(false)
347
+ const pushStateSpy = jest.spyOn(history, 'pushState').mockImplementation(() => {})
348
+ const fakeEvent = { currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } }
349
+
350
+ await openButtonCallback(fakeEvent)
351
+
352
+ expect(pushStateSpy).not.toHaveBeenCalled()
353
+ pushStateSpy.mockRestore()
354
+ })
355
+
356
+ it('_handleExitClick calls history.back() when history.state.isBack is true', () => {
357
+ const backSpy = jest.spyOn(history, 'back').mockImplementation(() => {})
358
+ Object.defineProperty(history, 'state', { value: { isBack: true }, writable: true, configurable: true })
359
+
360
+ const map = new InteractiveMap('map', {
361
+ behaviour: 'buttonFirst',
362
+ mapProvider: mapProviderMock,
363
+ mapViewParamKey: 'mv',
364
+ manageHistoryState: true,
365
+ preserveStateOnClose: false
366
+ })
367
+ jest.spyOn(map, 'removeApp').mockImplementation(() => {})
368
+
369
+ map._handleExitClick()
370
+
371
+ expect(backSpy).toHaveBeenCalled()
372
+ Object.defineProperty(history, 'state', { value: null, writable: true, configurable: true })
373
+ backSpy.mockRestore()
374
+ })
375
+
376
+ it('_handleExitClick skips history when manageHistoryState is false', () => {
377
+ const backSpy = jest.spyOn(history, 'back').mockImplementation(() => {})
378
+ const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {})
379
+
380
+ const map = new InteractiveMap('map', {
381
+ behaviour: 'buttonFirst',
382
+ mapProvider: mapProviderMock,
383
+ mapViewParamKey: 'mv',
384
+ manageHistoryState: false,
385
+ preserveStateOnClose: false
386
+ })
387
+ jest.spyOn(map, 'removeApp').mockImplementation(() => {})
388
+
389
+ map._handleExitClick()
390
+
391
+ expect(backSpy).not.toHaveBeenCalled()
392
+ expect(replaceStateSpy).not.toHaveBeenCalled()
393
+ backSpy.mockRestore()
394
+ replaceStateSpy.mockRestore()
395
+ })
396
+
397
+ it('loadApp emits APP_OPENED with statePreserved: false', async () => {
398
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
399
+ await map.loadApp()
400
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: false })
401
+ })
402
+
403
+ it('removeApp emits APP_CLOSED with statePreserved: false', () => {
404
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
405
+ map._root = {}
406
+ map.unmount = jest.fn()
407
+ map.removeApp()
408
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:closed', { statePreserved: false })
409
+ })
410
+
411
+ it('hideApp emits APP_CLOSED with statePreserved: true', () => {
412
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
413
+ map.hideApp()
414
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:closed', { statePreserved: true })
415
+ })
416
+
417
+ it('showApp emits APP_OPENED with statePreserved: true', () => {
418
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
419
+ map._isHidden = true
420
+ map.showApp()
421
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: true })
422
+ })
423
+
424
+ it('open() shows hidden map', () => {
425
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
426
+ const showSpy = jest.spyOn(map, 'showApp').mockImplementation(() => {})
427
+ map._isHidden = true
428
+ map.open()
429
+ expect(showSpy).toHaveBeenCalled()
430
+ showSpy.mockRestore()
431
+ })
432
+
433
+ it('open() loads map when not yet initialised', () => {
434
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
435
+ const loadSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
436
+ map._root = null
437
+ map._isHidden = false
438
+ map.open()
439
+ expect(loadSpy).toHaveBeenCalled()
440
+ loadSpy.mockRestore()
441
+ })
442
+
443
+ it('open() is a no-op when already open', () => {
444
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
445
+ const loadSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
446
+ const showSpy = jest.spyOn(map, 'showApp').mockImplementation(() => {})
447
+ map._root = {}
448
+ map._isHidden = false
449
+ map.open()
450
+ expect(loadSpy).not.toHaveBeenCalled()
451
+ expect(showSpy).not.toHaveBeenCalled()
452
+ loadSpy.mockRestore()
453
+ showSpy.mockRestore()
454
+ })
455
+
456
+ it('close() delegates to _handleExitClick', () => {
457
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
458
+ const exitSpy = jest.spyOn(map, '_handleExitClick').mockImplementation(() => {})
459
+ map.close()
460
+ expect(exitSpy).toHaveBeenCalled()
461
+ exitSpy.mockRestore()
462
+ })
463
+
342
464
  it('hideApp sets _isHidden and hides element', () => {
343
465
  const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
344
466
  map._openButton = mockButtonInstance
@@ -497,7 +619,7 @@ describe('InteractiveMap Public API Methods', () => {
497
619
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:removepanel', 'panel1')
498
620
 
499
621
  // New assertions for coverage
500
- expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', 'panel2')
622
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel2', focus: true })
501
623
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:hidepanel', 'panel3')
502
624
  })
503
625
 
@@ -14,11 +14,21 @@ function updatePageTitle ({ pageTitle, isFullscreen }) {
14
14
  }
15
15
 
16
16
  function getIsFullscreen (config) {
17
- const { id, behaviour } = config
18
- const hasViewParam = getQueryParam(defaults.mapViewParamKey) === id
17
+ const { id, behaviour, manageHistoryState } = config
19
18
 
20
- return behaviour === 'mapOnly' ||
21
- (hasViewParam && (behaviour === 'buttonFirst' || isHybridFullscreen(config)))
19
+ if (behaviour === 'mapOnly') {
20
+ return true
21
+ }
22
+
23
+ if (behaviour === 'buttonFirst') {
24
+ // When the SPA manages history, the app is always fullscreen when loaded
25
+ if (manageHistoryState === false) {
26
+ return true
27
+ }
28
+ return getQueryParam(defaults.mapViewParamKey) === id
29
+ }
30
+
31
+ return isHybridFullscreen(config) && getQueryParam(defaults.mapViewParamKey) === id
22
32
  }
23
33
 
24
34
  // -----------------------------------------------------------------------------
@@ -36,12 +46,14 @@ function getIsFullscreen (config) {
36
46
  * @param {string} mapInstance.config.behaviour - Behaviour mode ("mapOnly", "buttonFirst", "hybrid").
37
47
  * @param {string|number} mapInstance.config.containerHeight - Height to use when not fullscreen.
38
48
  * @param {HTMLElement} mapInstance.rootEl - Root element of the app.
49
+ * @param {Object} [options]
50
+ * @param {boolean} [options.isFullscreen] - Override the computed fullscreen state.
39
51
  * @returns {void}
40
52
  */
41
- function updateDOMState (mapInstance) {
53
+ function updateDOMState (mapInstance, { isFullscreen: isFullscreenOverride } = {}) {
42
54
  const { config, rootEl } = mapInstance
43
55
  const { pageTitle, behaviour, containerHeight } = config
44
- const isFullscreen = getIsFullscreen(config)
56
+ const isFullscreen = isFullscreenOverride ?? getIsFullscreen(config)
45
57
 
46
58
  if (['mapOnly', 'buttonFirst', 'hybrid'].includes(behaviour)) {
47
59
  toggleInertElements({ containerEl: rootEl, isFullscreen })