@defra/interactive-map 0.0.18-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 (73) 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/panel-definition.md +16 -0
  8. package/docs/api.md +28 -4
  9. package/docs/assets/basic-map.jpg +0 -0
  10. package/docs/assets/button-first.jpg +0 -0
  11. package/docs/assets/maker-panel.jpg +0 -0
  12. package/docs/examples/add-marker-with-panel.mdx +59 -0
  13. package/docs/examples/basic-map.mdx +24 -0
  14. package/docs/examples/button-map.mdx +24 -0
  15. package/docs/examples/index.mdx +49 -0
  16. package/docs/index.mdx +1 -1
  17. package/docs/plugins/interact.md +32 -1
  18. package/docs/plugins.md +1 -1
  19. package/docusaurus.config.cjs +9 -1
  20. package/package.json +1 -1
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  24. package/plugins/beta/draw-ml/dist/css/index.css +2 -19
  25. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  26. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  27. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  28. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  29. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  30. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  31. package/plugins/interact/dist/umd/index.js +1 -1
  32. package/plugins/interact/src/InteractInit.jsx +5 -3
  33. package/plugins/interact/src/api/clear.js +1 -1
  34. package/plugins/interact/src/api/selectMarker.js +14 -0
  35. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  36. package/plugins/interact/src/api/unselectMarker.js +14 -0
  37. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  38. package/plugins/interact/src/events.js +18 -30
  39. package/plugins/interact/src/events.test.js +113 -108
  40. package/plugins/interact/src/manifest.js +10 -2
  41. package/plugins/interact/src/reducer.js +36 -1
  42. package/plugins/interact/src/reducer.test.js +40 -1
  43. package/plugins/interact/src/utils/interactionModes.js +12 -0
  44. package/src/App/components/Panel/Panel.jsx +6 -6
  45. package/src/App/components/Panel/Panel.test.jsx +37 -0
  46. package/src/App/components/Viewport/Viewport.jsx +5 -15
  47. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  48. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  49. package/src/App/hooks/useInterfaceAPI.js +7 -7
  50. package/src/App/hooks/useInterfaceAPI.test.js +15 -9
  51. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  52. package/src/App/layout/Layout.jsx +1 -1
  53. package/src/App/layout/layout.module.scss +1 -8
  54. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  55. package/src/App/renderer/mapPanels.js +2 -1
  56. package/src/App/store/appActionsMap.js +4 -4
  57. package/src/App/store/appActionsMap.test.js +10 -0
  58. package/src/InteractiveMap/InteractiveMap.js +59 -11
  59. package/src/InteractiveMap/InteractiveMap.test.js +126 -4
  60. package/src/InteractiveMap/domStateManager.js +18 -6
  61. package/src/InteractiveMap/domStateManager.test.js +21 -0
  62. package/src/InteractiveMap/historyManager.js +28 -16
  63. package/src/InteractiveMap/historyManager.test.js +17 -0
  64. package/src/config/appConfig.js +2 -1
  65. package/src/config/appConfig.test.js +3 -13
  66. package/src/config/defaults.js +2 -1
  67. package/src/config/events.js +20 -21
  68. package/src/services/closeApp.js +1 -10
  69. package/src/services/closeApp.test.js +3 -43
  70. package/src/types.js +6 -1
  71. package/src/utils/mapStateSync.js +48 -10
  72. package/src/utils/mapStateSync.test.js +29 -9
  73. package/docs/examples.mdx +0 -70
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { render, fireEvent, cleanup } from '@testing-library/react'
2
+ import { render, cleanup } from '@testing-library/react'
3
3
  import { Viewport } from './Viewport.jsx'
4
4
  import { useConfig } from '../../store/configContext.js'
5
5
  import { useApp } from '../../store/appContext.js'
@@ -23,15 +23,18 @@ jest.mock('../CrossHair/CrossHair', () => ({ CrossHair: jest.fn(() => <div data-
23
23
  jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid='markers' />) }))
24
24
 
25
25
  describe('Viewport', () => {
26
- let keyboardHintPortalRef
26
+ let viewportEl
27
+ let mainEl
27
28
  const mockMapProvider = { initMap: jest.fn(), updateMap: jest.fn(), clearHighlightedLabel: jest.fn() }
28
29
 
29
30
  beforeEach(() => {
30
31
  cleanup()
31
32
  jest.clearAllMocks()
32
33
 
33
- keyboardHintPortalRef = { current: document.createElement('div') }
34
- document.body.appendChild(keyboardHintPortalRef.current)
34
+ viewportEl = document.createElement('div')
35
+ mainEl = document.createElement('div')
36
+ document.body.appendChild(viewportEl)
37
+ document.body.appendChild(mainEl)
35
38
 
36
39
  // ---------------------------
37
40
  // Hook mocks
@@ -47,7 +50,7 @@ describe('Viewport', () => {
47
50
  interfaceType: 'desktop',
48
51
  mode: 'default',
49
52
  previousMode: 'default',
50
- layoutRefs: { viewportRef: { current: null }, mainRef: { current: null }, safeZoneRef: { current: null } },
53
+ layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewportEl }, safeZoneRef: { current: null } },
51
54
  safeZoneInset: {}
52
55
  })
53
56
 
@@ -75,14 +78,17 @@ describe('Viewport', () => {
75
78
  useMapEvents.mockImplementation(() => {})
76
79
  })
77
80
 
78
- afterEach(() => document.body.removeChild(keyboardHintPortalRef.current))
81
+ afterEach(() => {
82
+ viewportEl.remove()
83
+ mainEl.remove()
84
+ })
79
85
 
80
86
  const renderViewport = () => {
81
- const { container, rerender, unmount } = render(<Viewport keyboardHintPortalRef={keyboardHintPortalRef} />)
87
+ const { container, rerender, unmount } = render(<Viewport />)
82
88
  const viewport = container.querySelector('.im-c-viewport')
83
89
  const mapContainer = container.querySelector('.im-c-viewport__map-container')
84
90
  const safeZone = container.querySelector('.im-c-viewport__safezone')
85
- const keyboardHint = keyboardHintPortalRef.current.querySelector('.im-c-viewport__keyboard-hint')
91
+ const keyboardHint = mainEl.querySelector('.im-c-viewport__keyboard-hint')
86
92
  const crossHair = container.querySelector('[data-testid="cross-hair"]')
87
93
  const markers = container.querySelector('[data-testid="markers"]')
88
94
  return { viewport, mapContainer, safeZone, keyboardHint, crossHair, markers, rerender, unmount }
@@ -109,14 +115,6 @@ describe('Viewport', () => {
109
115
  expect(keyboardHint.innerHTML).toBe('Press arrow keys')
110
116
  })
111
117
 
112
- it('handles focus and blur events updating keyboard hint visibility', () => {
113
- const { viewport, keyboardHint } = renderViewport()
114
- fireEvent.focus(viewport)
115
- fireEvent.blur(viewport)
116
- expect(keyboardHint).toBeInTheDocument()
117
- expect(keyboardHint.innerHTML).toBe('Press arrow keys')
118
- })
119
-
120
118
  it('attaches keyboard shortcuts', () => {
121
119
  renderViewport()
122
120
  expect(useKeyboardShortcuts).toHaveBeenCalled()
@@ -137,25 +135,10 @@ describe('Viewport', () => {
137
135
  interfaceType: 'desktop',
138
136
  mode: 'edit',
139
137
  previousMode: 'default',
140
- layoutRefs: { viewportRef: { current: viewport }, mainRef: { current: null }, safeZoneRef: { current: null } },
138
+ layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewport }, safeZoneRef: { current: null } },
141
139
  safeZoneInset: {}
142
140
  })
143
- rerender(<Viewport keyboardHintPortalRef={keyboardHintPortalRef} />)
141
+ rerender(<Viewport />)
144
142
  expect(focusMock).toHaveBeenCalled()
145
143
  })
146
-
147
- it('toggles main element class for keyboard hint and cleans up on unmount', () => {
148
- const mainEl = document.createElement('div')
149
- useApp.mockReturnValueOnce({
150
- interfaceType: 'desktop',
151
- mode: 'default',
152
- previousMode: 'default',
153
- layoutRefs: { viewportRef: { current: null }, mainRef: { current: mainEl }, safeZoneRef: { current: null } },
154
- safeZoneInset: {}
155
- })
156
- const { unmount } = renderViewport()
157
- expect(mainEl.classList.contains('im-o-app__main--keyboard-hint-visible')).toBe(true)
158
- unmount()
159
- expect(mainEl.classList.contains('im-o-app__main--keyboard-hint-visible')).toBe(false)
160
- })
161
144
  })
@@ -54,16 +54,16 @@ export const useInterfaceAPI = () => {
54
54
  }
55
55
  }
56
56
 
57
- const handleAppVisible = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true })
58
- const handleAppHidden = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
57
+ const handleAppOpened = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true })
58
+ const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
59
59
  const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
60
60
  const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
61
- const handleShowPanel = (id) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id } })
61
+ const handleShowPanel = ({ id, focus = true }) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus } })
62
62
  const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
63
63
  const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
64
64
 
65
- eventBus.on(events.APP_VISIBLE, handleAppVisible)
66
- eventBus.on(events.APP_HIDDEN, handleAppHidden)
65
+ eventBus.on(events.APP_OPENED, handleAppOpened)
66
+ eventBus.on(events.APP_CLOSED, handleAppClosed)
67
67
  eventBus.on(events.APP_ADD_BUTTON, handleAddButton)
68
68
  eventBus.on(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
69
69
  eventBus.on(events.APP_ADD_PANEL, handleAddPanel)
@@ -73,8 +73,8 @@ export const useInterfaceAPI = () => {
73
73
  eventBus.on(events.APP_ADD_CONTROL, handleAddControl)
74
74
 
75
75
  return () => {
76
- eventBus.off(events.APP_VISIBLE, handleAppVisible)
77
- eventBus.off(events.APP_HIDDEN, handleAppHidden)
76
+ eventBus.off(events.APP_OPENED, handleAppOpened)
77
+ eventBus.off(events.APP_CLOSED, handleAppClosed)
78
78
  eventBus.off(events.APP_ADD_BUTTON, handleAddButton)
79
79
  eventBus.off(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
80
80
  eventBus.off(events.APP_ADD_PANEL, handleAddPanel)
@@ -56,15 +56,15 @@ describe('useInterfaceAPI', () => {
56
56
  expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'item2', config: { id: 'item2', label: 'Item 2', isMenuItem: true } } })
57
57
  })
58
58
 
59
- it('dispatches TOGGLE_APP_VISIBLE true on app:visible', () => {
59
+ it('dispatches TOGGLE_APP_VISIBLE true on app:opened', () => {
60
60
  renderHook(() => useInterfaceAPI())
61
- act(() => mockEventBus.emit('app:visible'))
61
+ act(() => mockEventBus.emit('app:opened'))
62
62
  expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: true })
63
63
  })
64
64
 
65
- it('dispatches TOGGLE_APP_VISIBLE false on app:hidden', () => {
65
+ it('dispatches TOGGLE_APP_VISIBLE false on app:closed', () => {
66
66
  renderHook(() => useInterfaceAPI())
67
- act(() => mockEventBus.emit('app:hidden'))
67
+ act(() => mockEventBus.emit('app:closed'))
68
68
  expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: false })
69
69
  })
70
70
 
@@ -80,10 +80,16 @@ describe('useInterfaceAPI', () => {
80
80
  expect(mockDispatch).toHaveBeenCalledWith({ type: 'REMOVE_PANEL', payload: 'panel1' })
81
81
  })
82
82
 
83
- it('dispatches OPEN_PANEL on app:showpanel', () => {
83
+ it('dispatches OPEN_PANEL with focusOnOpen:true on app:showpanel by default', () => {
84
84
  renderHook(() => useInterfaceAPI())
85
- act(() => mockEventBus.emit('app:showpanel', 'panel1'))
86
- expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1' } })
85
+ act(() => mockEventBus.emit('app:showpanel', { id: 'panel1' }))
86
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: true } })
87
+ })
88
+
89
+ it('dispatches OPEN_PANEL with focusOnOpen:false when focus:false', () => {
90
+ renderHook(() => useInterfaceAPI())
91
+ act(() => mockEventBus.emit('app:showpanel', { id: 'panel1', focus: false }))
92
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: false } })
87
93
  })
88
94
 
89
95
  it('dispatches CLOSE_PANEL on app:hidepanel', () => {
@@ -143,8 +149,8 @@ describe('useInterfaceAPI', () => {
143
149
  it('removes all event listeners on unmount', () => {
144
150
  const { unmount } = renderHook(() => useInterfaceAPI())
145
151
  unmount()
146
- expect(mockEventBus.off).toHaveBeenCalledWith('app:visible', expect.any(Function))
147
- expect(mockEventBus.off).toHaveBeenCalledWith('app:hidden', expect.any(Function))
152
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:opened', expect.any(Function))
153
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:closed', expect.any(Function))
148
154
  expect(mockEventBus.off).toHaveBeenCalledWith('app:addbutton', expect.any(Function))
149
155
  expect(mockEventBus.off).toHaveBeenCalledWith('app:togglebuttonstate', expect.any(Function))
150
156
  expect(mockEventBus.off).toHaveBeenCalledWith('app:addpanel', expect.any(Function))
@@ -51,80 +51,72 @@ const subSlotMaxHeight = (columnHeight, siblingButtons, gap) =>
51
51
  * It does not dispatch the safe zone — safe zone dispatch is owned entirely by
52
52
  * Effect 3 to prevent jumps on panel open/close and other non-structural resizes.
53
53
  */
54
- export function useLayoutMeasurements () {
55
- const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
56
- const { mapSize, isMapReady } = useMap()
57
-
54
+ function calculateLayout (layoutRefs) {
58
55
  const {
59
- appContainerRef,
60
- mainRef,
61
- bannerRef,
62
- topRef,
63
- topLeftColRef,
64
- topRightColRef,
65
- leftTopRef,
66
- leftBottomRef,
67
- rightTopRef,
68
- rightBottomRef,
69
- bottomRef,
70
- bottomRightRef,
71
- attributionsRef,
72
- drawerRef,
73
- actionsRef
56
+ appContainerRef, mainRef, topRef, topLeftColRef, topRightColRef,
57
+ bottomRef, attributionsRef, bottomRightRef, leftTopRef, leftBottomRef,
58
+ rightTopRef, rightBottomRef
74
59
  } = layoutRefs
75
60
 
76
- // --------------------------------
77
- // 1. Calculate layout CSS vars (pure side effect, no dispatch)
78
- // --------------------------------
79
- const calculateLayout = () => {
80
- const appContainer = appContainerRef.current
81
- const main = mainRef.current
82
- const top = topRef.current
83
- const topLeftCol = topLeftColRef.current
84
- const topRightCol = topRightColRef.current
85
- const bottom = bottomRef.current
86
- const attributions = attributionsRef.current
87
-
88
- if ([main, top, bottom].some(r => !r)) {
89
- return
90
- }
61
+ const appContainer = appContainerRef.current
62
+ const main = mainRef.current
63
+ const top = topRef.current
64
+ const topLeftCol = topLeftColRef.current
65
+ const topRightCol = topRightColRef.current
66
+ const bottom = bottomRef.current
67
+ const attributions = attributionsRef.current
91
68
 
92
- const root = document.documentElement
93
- const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
94
-
95
- // === Top column width ===
96
- appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
97
-
98
- // === Left container offsets ===
99
- const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
100
- const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
101
- appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
102
- appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
103
- appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
104
-
105
- // === Right container offsets ===
106
- // Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
107
- // bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
108
- // the padding between the bottom of the bottom container and the bottom of main.
109
- const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
110
- const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
111
- const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
112
- const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
113
- const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
114
- const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
115
- appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
116
- appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
117
- appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
118
-
119
- // === Sub-slot panel max-heights ===
120
- appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
121
- appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
122
- appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
123
- appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
69
+ if ([main, top, bottom].some(r => !r)) {
70
+ return
124
71
  }
125
72
 
73
+ const root = document.documentElement
74
+ const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
75
+
76
+ // === Top column width ===
77
+ appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
78
+
79
+ // === Left container offsets ===
80
+ const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
81
+ const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
82
+ appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
83
+ appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
84
+ appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
85
+
86
+ // === Right container offsets ===
87
+ // Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
88
+ // bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
89
+ // the padding between the bottom of the bottom container and the bottom of main.
90
+ const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
91
+ const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
92
+ const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
93
+ const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
94
+ const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
95
+ const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
96
+ appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
97
+ appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
98
+ appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
99
+
100
+ // === Keyboard hint bottom offset ===
101
+ // Distance from the bottom of im-o-app__bottom to the bottom of im-o-app__main.
102
+ // Used to position the hint above the bottom bar (and above drawers on mobile).
103
+ appContainer.style.setProperty('--keyboard-hint-bottom', `${main.offsetHeight - bottom.offsetTop - bottom.offsetHeight}px`)
104
+
105
+ // === Sub-slot panel max-heights ===
106
+ appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
107
+ appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
108
+ appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
109
+ appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
110
+ }
111
+
112
+ export function useLayoutMeasurements () {
113
+ const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
114
+ const { mapSize, isMapReady } = useMap()
115
+
116
+ const { bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, actionsRef } = layoutRefs
117
+
126
118
  // --------------------------------
127
- // 2. Clear the evaluated flag when structural inputs change so the safe zone
119
+ // 1. Clear the evaluated flag when structural inputs change so the safe zone
128
120
  // is not dispatched until useButtonStateEvaluator has completed a full
129
121
  // pass with the new app/map state and set PLUGINS_EVALUATED.
130
122
  // --------------------------------
@@ -133,7 +125,7 @@ export function useLayoutMeasurements () {
133
125
  }, [breakpoint, mapSize, isMapReady, appVisible, isFullscreen])
134
126
 
135
127
  // --------------------------------
136
- // 3. Once all plugin button props have been evaluated (arePluginsEvaluated),
128
+ // 2. Once all plugin button props have been evaluated (arePluginsEvaluated),
137
129
  // recalculate layout and dispatch the safe zone inset.
138
130
  // RAF required to ensure browser layout is committed before measuring.
139
131
  // --------------------------------
@@ -142,7 +134,7 @@ export function useLayoutMeasurements () {
142
134
  return
143
135
  }
144
136
  requestAnimationFrame(() => {
145
- calculateLayout()
137
+ calculateLayout(layoutRefs)
146
138
  const safeZoneInset = getSafeZoneInset(layoutRefs)
147
139
  if (safeZoneInset) {
148
140
  dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } })
@@ -151,13 +143,13 @@ export function useLayoutMeasurements () {
151
143
  }, [arePluginsEvaluated])
152
144
 
153
145
  // --------------------------------
154
- // 4. Recalculate CSS vars whenever observed elements resize (panels, banner,
146
+ // 3. Recalculate CSS vars whenever observed elements resize (panels, banner,
155
147
  // actions buttons, etc.). Safe zone is intentionally not dispatched here —
156
- // that is Effect 3's responsibility.
148
+ // that is Effect 2's responsibility.
157
149
  // --------------------------------
158
150
  useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef], () => {
159
151
  requestAnimationFrame(() => {
160
- calculateLayout()
152
+ calculateLayout(layoutRefs)
161
153
  })
162
154
  })
163
155
  }
@@ -36,7 +36,7 @@ export const Layout = () => {
36
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} />
@@ -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}
@@ -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()
@@ -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
  }