@defra/interactive-map 0.0.10-alpha → 0.0.12-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 (121) hide show
  1. package/README.md +1 -1
  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/button-definition.md +21 -3
  8. package/docs/api/panel-definition.md +10 -12
  9. package/docs/api.md +80 -7
  10. package/docs/demo.mdx +70 -0
  11. package/docs/index.md +0 -4
  12. package/docs/plugins/plugin-context.md +3 -3
  13. package/docs/plugins/plugin-descriptor.md +37 -0
  14. package/docs/plugins/plugin-manifest.md +1 -1
  15. package/docusaurus.config.cjs +55 -25
  16. package/package.json +18 -9
  17. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  19. package/plugins/beta/datasets/src/manifest.js +3 -3
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/src/events.js +4 -14
  23. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +1 -3
  24. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  25. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  26. package/plugins/beta/map-styles/src/manifest.js +3 -3
  27. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  28. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  29. package/plugins/beta/use-location/src/manifest.js +7 -7
  30. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  31. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  32. package/plugins/interact/src/InteractInit.jsx +28 -6
  33. package/plugins/interact/src/InteractInit.test.js +19 -5
  34. package/plugins/interact/src/events.js +17 -15
  35. package/plugins/interact/src/events.test.js +25 -16
  36. package/plugins/search/dist/css/index.css +1 -1
  37. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  38. package/plugins/search/dist/esm/index.js +1 -1
  39. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  40. package/plugins/search/dist/umd/index.js +1 -1
  41. package/plugins/search/src/Search.jsx +9 -3
  42. package/plugins/search/src/Search.test.jsx +26 -6
  43. package/plugins/search/src/components/Form/Form.jsx +35 -7
  44. package/plugins/search/src/components/Form/Form.module.scss +27 -0
  45. package/plugins/search/src/components/Form/Form.test.jsx +99 -2
  46. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
  47. package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
  48. package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
  49. package/plugins/search/src/datasets.js +15 -11
  50. package/plugins/search/src/datasets.test.js +17 -2
  51. package/plugins/search/src/events/fetchSuggestions.js +1 -1
  52. package/plugins/search/src/index.js +1 -1
  53. package/plugins/search/src/index.test.js +4 -4
  54. package/plugins/search/src/reducer.js +9 -4
  55. package/plugins/search/src/reducer.test.js +12 -7
  56. package/plugins/search/src/search.scss +5 -1
  57. package/plugins/search/src/utils/parseOsNamesResults.js +18 -2
  58. package/plugins/search/src/utils/parseOsNamesResults.test.js +33 -15
  59. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  60. package/providers/beta/esri/src/appEvents.js +8 -2
  61. package/providers/beta/esri/src/esriProvider.js +25 -17
  62. package/providers/beta/esri/src/mapEvents.js +41 -4
  63. package/providers/beta/esri/src/utils/coords.js +34 -1
  64. package/providers/beta/esri/src/utils/coords.test.js +126 -0
  65. package/providers/beta/esri/src/utils/spatial.js +47 -1
  66. package/providers/beta/esri/src/utils/spatial.test.js +55 -0
  67. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  68. package/providers/maplibre/dist/esm/index.js +1 -1
  69. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  70. package/providers/maplibre/dist/umd/index.js +1 -1
  71. package/providers/maplibre/src/appEvents.js +10 -1
  72. package/providers/maplibre/src/appEvents.test.js +13 -4
  73. package/providers/maplibre/src/index.js +5 -13
  74. package/providers/maplibre/src/index.test.js +34 -15
  75. package/providers/maplibre/src/mapEvents.js +9 -1
  76. package/providers/maplibre/src/maplibreProvider.js +25 -15
  77. package/providers/maplibre/src/maplibreProvider.test.js +28 -2
  78. package/providers/maplibre/src/utils/spatial.js +51 -0
  79. package/providers/maplibre/src/utils/spatial.test.js +47 -0
  80. package/src/App/components/Actions/Actions.module.scss +5 -4
  81. package/src/App/components/MapButton/MapButton.jsx +4 -16
  82. package/src/App/components/MapButton/MapButton.module.scss +12 -12
  83. package/src/App/components/MapButton/MapButton.test.jsx +0 -9
  84. package/src/App/components/Panel/Panel.jsx +6 -6
  85. package/src/App/components/Panel/Panel.test.jsx +14 -15
  86. package/src/App/components/Viewport/MapController.jsx +6 -1
  87. package/src/App/hooks/useLayoutMeasurements.js +1 -1
  88. package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
  89. package/src/App/hooks/useMapProviderOverrides.js +21 -1
  90. package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
  91. package/src/App/hooks/useMarkersAPI.js +5 -3
  92. package/src/App/hooks/useModalPanelBehaviour.js +19 -2
  93. package/src/App/hooks/useModalPanelBehaviour.test.js +84 -60
  94. package/src/App/hooks/useVisibleGeometry.js +100 -0
  95. package/src/App/hooks/useVisibleGeometry.test.js +331 -0
  96. package/src/App/layout/Layout.jsx +5 -5
  97. package/src/App/layout/layout.module.scss +2 -4
  98. package/src/App/registry/panelRegistry.js +1 -10
  99. package/src/App/registry/panelRegistry.test.js +6 -11
  100. package/src/App/renderer/HtmlElementHost.jsx +12 -3
  101. package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
  102. package/src/App/renderer/mapButtons.js +128 -28
  103. package/src/App/renderer/mapButtons.test.js +119 -19
  104. package/src/App/renderer/pluginWrapper.js +3 -2
  105. package/src/App/renderer/slots.js +1 -1
  106. package/src/App/store/AppProvider.jsx +1 -0
  107. package/src/App/store/MapProvider.jsx +18 -5
  108. package/src/App/store/MapProvider.test.jsx +56 -1
  109. package/src/App/store/appActionsMap.js +17 -9
  110. package/src/App/store/appActionsMap.test.js +33 -7
  111. package/src/App/store/appDispatchMiddleware.js +19 -0
  112. package/src/App/store/appDispatchMiddleware.test.js +56 -0
  113. package/src/App/store/mapActionsMap.js +4 -7
  114. package/src/InteractiveMap/InteractiveMap.js +18 -0
  115. package/src/InteractiveMap/InteractiveMap.test.js +12 -0
  116. package/src/config/appConfig.js +17 -15
  117. package/src/config/events.js +41 -4
  118. package/src/config/getInitialOpenPanels.js +2 -2
  119. package/src/config/getInitialOpenPanels.test.js +7 -7
  120. package/src/types.js +22 -11
  121. package/src/utils/getValueForStyle.js +1 -1
@@ -6,6 +6,7 @@ import { useMapStateSync } from '../../hooks/useMapStateSync'
6
6
  import { useMapURLSync } from '../../hooks/useMapURLSync'
7
7
  import { useMapAnnouncements } from '../../hooks/useMapAnnouncements'
8
8
  import { useMapProviderOverrides } from '../../hooks/useMapProviderOverrides'
9
+ import { useVisibleGeometry } from '../../hooks/useVisibleGeometry'
9
10
  import { getInitialMapState } from '../../../utils/mapStateSync'
10
11
  import { scaleFactor } from '../../../config/appConfig'
11
12
  import { scalePoints } from '../../../utils/scalePoints.js'
@@ -38,7 +39,8 @@ export const MapController = ({ mapContainerRef }) => {
38
39
  center: initialState.center,
39
40
  zoom: initialState.zoom,
40
41
  bounds: initialState.bounds,
41
- mapStyle
42
+ mapStyle,
43
+ mapSize
42
44
  })
43
45
  })
44
46
 
@@ -57,6 +59,9 @@ export const MapController = ({ mapContainerRef }) => {
57
59
  // Override mapProvider functions
58
60
  useMapProviderOverrides()
59
61
 
62
+ // Pan/zoom to keep visibleGeometry visible when panels open
63
+ useVisibleGeometry()
64
+
60
65
  // Update padding when breakpoint or mapSize change
61
66
  useEffect(() => {
62
67
  if (!isMapReady || !syncMapPadding) {
@@ -81,7 +81,7 @@ export function useLayoutMeasurements () {
81
81
  // --------------------------------
82
82
  // 3. Recaluclate CSS vars when elements resize
83
83
  // --------------------------------
84
- useResizeObserver([bannerRef, mainRef, topRef, actionsRef, footerRef], () => {
84
+ useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, footerRef], () => {
85
85
  requestAnimationFrame(() => {
86
86
  calculateLayout()
87
87
  })
@@ -114,7 +114,7 @@ describe('useLayoutMeasurements', () => {
114
114
  const { layoutRefs } = setup()
115
115
  renderHook(() => useLayoutMeasurements())
116
116
  expect(useResizeObserver).toHaveBeenCalledWith(
117
- [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.actionsRef, layoutRefs.footerRef],
117
+ [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.footerRef],
118
118
  expect.any(Function)
119
119
  )
120
120
  layoutRefs.appContainerRef.current.style.setProperty.mockClear()
@@ -2,12 +2,13 @@ import { useEffect, useRef } from 'react'
2
2
  import { useConfig } from '../store/configContext.js'
3
3
  import { useApp } from '../store/appContext.js'
4
4
  import { useMap } from '../store/mapContext.js'
5
+ import { EVENTS as events } from '../../config/events.js'
5
6
  import { getSafeZoneInset } from '../../utils/getSafeZoneInset.js'
6
7
  import { scalePoints } from '../../utils/scalePoints.js'
7
8
  import { scaleFactor } from '../../config/appConfig.js'
8
9
 
9
10
  export const useMapProviderOverrides = () => {
10
- const { mapProvider } = useConfig()
11
+ const { mapProvider, eventBus } = useConfig()
11
12
  const { dispatch: appDispatch, layoutRefs } = useApp()
12
13
  const { mapSize } = useMap()
13
14
 
@@ -67,4 +68,23 @@ export const useMapProviderOverrides = () => {
67
68
  mapProvider.setView = originalSetView
68
69
  }
69
70
  }, [mapProvider, appDispatch, layoutRefs, mapSize])
71
+
72
+ // Forward public API events to the (overridden) mapProvider methods so that
73
+ // interactiveMap.fitToBounds() and interactiveMap.setView() respect safe zone padding.
74
+ useEffect(() => {
75
+ if (!mapProvider || !eventBus) {
76
+ return undefined
77
+ }
78
+
79
+ const handleFitToBounds = (bbox) => mapProvider.fitToBounds(bbox)
80
+ const handleSetView = (opts) => mapProvider.setView(opts)
81
+
82
+ eventBus.on(events.MAP_FIT_TO_BOUNDS, handleFitToBounds)
83
+ eventBus.on(events.MAP_SET_VIEW, handleSetView)
84
+
85
+ return () => {
86
+ eventBus.off(events.MAP_FIT_TO_BOUNDS, handleFitToBounds)
87
+ eventBus.off(events.MAP_SET_VIEW, handleSetView)
88
+ }
89
+ }, [mapProvider, eventBus])
70
90
  }
@@ -22,15 +22,21 @@ const setup = (overrides = {}) => {
22
22
  setPadding: jest.fn(),
23
23
  ...overrides.mapProvider
24
24
  }
25
+ const capturedHandlers = {}
26
+ const eventBus = {
27
+ on: jest.fn((event, handler) => { capturedHandlers[event] = handler }),
28
+ off: jest.fn(),
29
+ ...overrides.eventBus
30
+ }
25
31
 
26
- useConfig.mockReturnValue({ mapProvider, ...overrides.config })
32
+ useConfig.mockReturnValue({ mapProvider, eventBus, ...overrides.config })
27
33
  useApp.mockReturnValue({ dispatch, layoutRefs, ...overrides.app })
28
34
  useMap.mockReturnValue({ mapSize: 'md', ...overrides.map })
29
35
 
30
36
  getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 15, left: 5 })
31
37
  scalePoints.mockReturnValue({ top: 20, right: 10, bottom: 30, left: 10 })
32
38
 
33
- return { dispatch, layoutRefs, mapProvider }
39
+ return { dispatch, layoutRefs, mapProvider, eventBus, capturedHandlers }
34
40
  }
35
41
 
36
42
  describe('useMapProviderOverrides', () => {
@@ -133,4 +139,47 @@ describe('useMapProviderOverrides', () => {
133
139
 
134
140
  expect(mapProvider.fitToBounds).not.toBe(firstOverride)
135
141
  })
142
+
143
+ test('subscribes to MAP_FIT_TO_BOUNDS and MAP_SET_VIEW on eventBus', () => {
144
+ const { eventBus } = setup()
145
+ renderHook(() => useMapProviderOverrides())
146
+
147
+ expect(eventBus.on).toHaveBeenCalledWith('map:fittobounds', expect.any(Function))
148
+ expect(eventBus.on).toHaveBeenCalledWith('map:setview', expect.any(Function))
149
+ })
150
+
151
+ test('MAP_FIT_TO_BOUNDS event forwards bbox to mapProvider.fitToBounds', () => {
152
+ const { mapProvider, capturedHandlers } = setup()
153
+ const originalFitToBounds = mapProvider.fitToBounds
154
+ renderHook(() => useMapProviderOverrides())
155
+
156
+ capturedHandlers['map:fittobounds']([0, 0, 1, 1])
157
+
158
+ expect(originalFitToBounds).toHaveBeenCalledWith([0, 0, 1, 1])
159
+ })
160
+
161
+ test('MAP_SET_VIEW event forwards opts to mapProvider.setView', () => {
162
+ const { mapProvider, capturedHandlers } = setup()
163
+ const originalSetView = mapProvider.setView
164
+ renderHook(() => useMapProviderOverrides())
165
+
166
+ capturedHandlers['map:setview']({ center: [1, 2], zoom: 10 })
167
+
168
+ expect(originalSetView).toHaveBeenCalledWith({ center: [1, 2], zoom: 10 })
169
+ })
170
+
171
+ test('unsubscribes from MAP_FIT_TO_BOUNDS and MAP_SET_VIEW on unmount', () => {
172
+ const { eventBus } = setup()
173
+ const { unmount } = renderHook(() => useMapProviderOverrides())
174
+
175
+ unmount()
176
+
177
+ expect(eventBus.off).toHaveBeenCalledWith('map:fittobounds', expect.any(Function))
178
+ expect(eventBus.off).toHaveBeenCalledWith('map:setview', expect.any(Function))
179
+ })
180
+
181
+ test('skips event subscriptions when eventBus is null', () => {
182
+ setup({ config: { eventBus: null } })
183
+ expect(() => renderHook(() => useMapProviderOverrides())).not.toThrow()
184
+ })
136
185
  })
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef } from 'react'
1
+ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
2
2
  import { useConfig } from '../store/configContext.js'
3
3
  import { useMap } from '../store/mapContext.js'
4
4
  import { useService } from '../store/serviceContext.js'
@@ -99,8 +99,10 @@ export const useMarkers = () => {
99
99
  const { markers, dispatch, mapSize, isMapReady } = useMap()
100
100
  const markerRefs = useRef(new Map())
101
101
 
102
- // Attach add, remove, and getMarker methods to the markers store object
103
- useEffect(() => {
102
+ // Attach add, remove, and getMarker methods to the markers store object.
103
+ // useLayoutEffect ensures these are assigned before paint so rapid clicks can't
104
+ // arrive between a render (new markers object) and the async useEffect assignment.
105
+ useLayoutEffect(() => {
104
106
  if (!mapProvider) {
105
107
  return
106
108
  }
@@ -75,11 +75,28 @@ export function useModalPanelBehaviour ({
75
75
  const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
76
76
 
77
77
  useResizeObserver([mainRef], () => {
78
- if (!isModal || !buttonContainerEl || !mainRef.current) {
78
+ if (!isModal || !mainRef.current) {
79
79
  return
80
80
  }
81
+
82
+ // buttonContainerEl is only defined for button-slot panels (bpConfig.slot ends with '-button').
83
+ // Skip positioning for all other modal types.
84
+ if (buttonContainerEl === undefined) {
85
+ return
86
+ }
87
+
88
+ // Dynamically query the current controlling button via aria-controls to handle the case
89
+ // where the button has remounted after a breakpoint change (stale triggeringElement).
90
+ const panelElId = panelRef.current?.id
91
+ const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
92
+ const effectiveContainer = currentButtonEl?.parentElement ?? (buttonContainerEl?.isConnected ? buttonContainerEl : null)
93
+
94
+ if (!effectiveContainer) {
95
+ return
96
+ }
97
+
81
98
  const mainRect = mainRef.current.getBoundingClientRect()
82
- const buttonRect = buttonContainerEl.getBoundingClientRect()
99
+ const buttonRect = effectiveContainer.getBoundingClientRect()
83
100
  const offsetTop = buttonRect.top - mainRect.top
84
101
  const offsetRight = Math.round(mainRect.right - buttonRect.right + buttonRect.width + dividerGap)
85
102
  root.style.setProperty('--modal-inset', `${offsetTop}px ${offsetRight}px auto auto`)
@@ -17,12 +17,17 @@ describe('useModalPanelBehaviour', () => {
17
17
  main: { current: document.createElement('div') },
18
18
  panel: { current: document.createElement('div') }
19
19
  }
20
+ // Give panel an ID for aria-controls tests
21
+ refs.panel.current.id = 'modal-panel-id'
22
+
20
23
  elements = {
21
24
  buttonContainer: document.createElement('div'),
22
25
  root: document.createElement('div')
23
26
  }
27
+
24
28
  elements.root.appendChild(refs.panel.current)
25
29
  document.body.appendChild(elements.root)
30
+
26
31
  handleClose = jest.fn()
27
32
  jest.clearAllMocks()
28
33
  document.documentElement.style.setProperty('--modal-inset', '')
@@ -32,13 +37,17 @@ describe('useModalPanelBehaviour', () => {
32
37
  document.body.innerHTML = ''
33
38
  })
34
39
 
35
- const TestComponent = ({ isModal = true }) => {
40
+ const TestComponent = ({
41
+ isModal = true,
42
+ buttonContainerEl,
43
+ rootEl = elements.root
44
+ }) => {
36
45
  useModalPanelBehaviour({
37
46
  mainRef: refs.main,
38
47
  panelRef: refs.panel,
39
48
  isModal,
40
- rootEl: elements.root,
41
- buttonContainerEl: elements.buttonContainer,
49
+ rootEl,
50
+ buttonContainerEl,
42
51
  handleClose
43
52
  })
44
53
  return null
@@ -63,66 +72,51 @@ describe('useModalPanelBehaviour', () => {
63
72
  )
64
73
  })
65
74
 
66
- it('updates --modal-inset on resize', () => {
67
- useResizeObserverModule.useResizeObserver.mockImplementation((_, cb) => cb())
68
- Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
69
- value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 })
75
+ describe('positioning (--modal-inset)', () => {
76
+ beforeEach(() => {
77
+ // Force ResizeObserver to run the callback immediately
78
+ useResizeObserverModule.useResizeObserver.mockImplementation((_, cb) => cb())
79
+
80
+ Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
81
+ value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 }),
82
+ configurable: true
83
+ })
84
+ Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
85
+ value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 }),
86
+ configurable: true
87
+ })
70
88
  })
71
- Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
72
- value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 })
73
- })
74
-
75
- render(<TestComponent />)
76
-
77
- const inset = getComputedStyle(document.documentElement).getPropertyValue('--modal-inset')
78
- expect(inset).toContain('10px')
79
- })
80
-
81
- describe('backdrop clicks', () => {
82
- const createBackdrop = (appendTo) => {
83
- const backdrop = document.createElement('div')
84
- backdrop.className = 'im-o-app__modal-backdrop'
85
- appendTo.appendChild(backdrop)
86
- return backdrop
87
- }
88
89
 
89
- it('calls handleClose when backdrop inside rootEl is clicked', () => {
90
- const backdrop = createBackdrop(elements.root)
90
+ it('hits the buttonContainerEl === undefined branch', () => {
91
+ refs.main.current = document.createElement('div') // mainRef must exist
91
92
  render(<TestComponent />)
92
- fireEvent.click(backdrop)
93
- expect(handleClose).toHaveBeenCalled()
94
- })
95
93
 
96
- it('does not call handleClose when backdrop outside rootEl is clicked', () => {
97
- const backdrop = createBackdrop(document.body)
98
- render(<TestComponent />)
99
- fireEvent.click(backdrop)
100
- expect(handleClose).not.toHaveBeenCalled()
101
- })
94
+ // Manually trigger ResizeObserver callback (if mocked)
95
+ const callback = useResizeObserverModule.useResizeObserver.mock.calls[0][1]
96
+ callback()
102
97
 
103
- it('does not call handleClose when non-backdrop element is clicked', () => {
104
- elements.root.appendChild(document.createElement('div'))
105
- render(<TestComponent />)
106
- fireEvent.click(elements.root.firstChild)
107
- expect(handleClose).not.toHaveBeenCalled()
98
+ // Expect CSS variable not set, just to assert callback ran
99
+ const inset = document.documentElement.style.getPropertyValue('--modal-inset')
100
+ expect(inset).toBe('')
108
101
  })
109
- })
110
102
 
111
- it('toggles inert elements on mount and cleanup', () => {
112
- const { unmount } = render(<TestComponent />)
103
+ it('updates --modal-inset via aria-controls when buttonContainerEl is stale', () => {
104
+ const button = document.createElement('button')
105
+ button.setAttribute('aria-controls', 'modal-panel-id')
106
+ elements.buttonContainer.appendChild(button)
107
+ document.body.appendChild(elements.buttonContainer)
113
108
 
114
- expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith({
115
- containerEl: refs.panel.current,
116
- isFullscreen: true,
117
- boundaryEl: elements.root
118
- })
109
+ const staleEl = document.createElement('div') // detached
110
+ render(<TestComponent buttonContainerEl={staleEl} />)
119
111
 
120
- unmount()
112
+ const inset = document.documentElement.style.getPropertyValue('--modal-inset')
113
+ expect(inset).toContain('10px')
114
+ })
121
115
 
122
- expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith({
123
- containerEl: refs.panel.current,
124
- isFullscreen: false,
125
- boundaryEl: elements.root
116
+ it('skips update when effectiveContainer cannot be resolved', () => {
117
+ render(<TestComponent buttonContainerEl={null} />)
118
+ const inset = document.documentElement.style.getPropertyValue('--modal-inset')
119
+ expect(inset).toBe('')
126
120
  })
127
121
  })
128
122
 
@@ -138,6 +132,20 @@ describe('useModalPanelBehaviour', () => {
138
132
  expect(refs.panel.current.focus).toHaveBeenCalled()
139
133
  })
140
134
 
135
+ // COVERS LINE 44 (The early return branch)
136
+ it('does not redirect focus when focus moves completely outside the app root', () => {
137
+ refs.panel.current.focus = jest.fn()
138
+ render(<TestComponent />)
139
+
140
+ const externalEl = document.createElement('button')
141
+ document.body.appendChild(externalEl) // Outside elements.root
142
+
143
+ dispatchFocusIn(externalEl)
144
+
145
+ // Since isInsideApp is false, it should hit the "return" and not call focus()
146
+ expect(refs.panel.current.focus).not.toHaveBeenCalled()
147
+ })
148
+
141
149
  it('does not redirect focus when focus is already inside panel', () => {
142
150
  refs.panel.current.focus = jest.fn()
143
151
  render(<TestComponent />)
@@ -149,23 +157,39 @@ describe('useModalPanelBehaviour', () => {
149
157
  expect(refs.panel.current.focus).not.toHaveBeenCalled()
150
158
  })
151
159
 
152
- it('handles edge cases gracefully', () => {
160
+ it('handles null focus targets gracefully', () => {
153
161
  render(<TestComponent />)
154
-
155
162
  dispatchFocusIn(null)
163
+ expect(true).toBe(true)
164
+ })
165
+ })
166
+
167
+ describe('backdrop and inert', () => {
168
+ it('calls handleClose when backdrop inside rootEl is clicked', () => {
169
+ const backdrop = document.createElement('div')
170
+ backdrop.className = 'im-o-app__modal-backdrop'
171
+ elements.root.appendChild(backdrop)
156
172
 
157
- refs.panel.current = null
158
- dispatchFocusIn(document.body)
173
+ render(<TestComponent />)
174
+ fireEvent.click(backdrop)
175
+ expect(handleClose).toHaveBeenCalled()
176
+ })
159
177
 
160
- expect(true).toBe(true) // No errors thrown
178
+ it('toggles inert elements on mount and cleanup', () => {
179
+ const { unmount } = render(<TestComponent />)
180
+ expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith(
181
+ expect.objectContaining({ isFullscreen: true })
182
+ )
183
+ unmount()
184
+ expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith(
185
+ expect.objectContaining({ isFullscreen: false })
186
+ )
161
187
  })
162
188
  })
163
189
 
164
190
  it('does nothing when isModal is false', () => {
165
191
  render(<TestComponent isModal={false} />)
166
-
167
192
  fireEvent.keyDown(refs.panel.current, { key: 'Escape' })
168
193
  expect(handleClose).not.toHaveBeenCalled()
169
- expect(toggleInertModule.toggleInertElements).not.toHaveBeenCalled()
170
194
  })
171
195
  })
@@ -0,0 +1,100 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { useConfig } from '../store/configContext.js'
3
+ import { useApp } from '../store/appContext.js'
4
+ import { EVENTS as events } from '../../config/events.js'
5
+
6
+ export const getGeometryType = (geojson) => {
7
+ if (!geojson) {
8
+ return null
9
+ }
10
+ if (geojson.type === 'Feature') {
11
+ return geojson.geometry?.type
12
+ }
13
+ return geojson.type
14
+ }
15
+
16
+ const isPointGeometry = (geojson) => {
17
+ const type = getGeometryType(geojson)
18
+ return type === 'Point' || type === 'MultiPoint'
19
+ }
20
+
21
+ export const getPointCoordinates = (geojson) => {
22
+ if (geojson.type === 'Feature') {
23
+ return getPointCoordinates(geojson.geometry)
24
+ }
25
+ if (geojson.type === 'Point') {
26
+ return geojson.coordinates
27
+ }
28
+ if (geojson.type === 'MultiPoint') {
29
+ return geojson.coordinates[0]
30
+ }
31
+ return null
32
+ }
33
+
34
+ const SLOT_REFS = {
35
+ inset: 'insetRef',
36
+ bottom: 'bottomRef',
37
+ side: 'sideRef'
38
+ }
39
+
40
+ export const useVisibleGeometry = () => {
41
+ const { mapProvider, eventBus } = useConfig()
42
+ const { layoutRefs, panelConfig, panelRegistry, breakpoint } = useApp()
43
+
44
+ const latestRef = useRef({ layoutRefs, panelConfig, panelRegistry, breakpoint })
45
+ latestRef.current = { layoutRefs, panelConfig, panelRegistry, breakpoint }
46
+
47
+ useEffect(() => {
48
+ if (!mapProvider || !eventBus) {
49
+ return undefined
50
+ }
51
+
52
+ const handlePanelOpened = ({ panelId, slot: eventSlot, visibleGeometry: eventVisibleGeometry }) => {
53
+ const { panelConfig: config, panelRegistry: registry, layoutRefs: refs, breakpoint: bp } = latestRef.current
54
+ const resolvedConfig = config?.[panelId] ? config : (registry?.getPanelConfig() ?? config)
55
+ const visibleGeometry = eventVisibleGeometry ?? resolvedConfig?.[panelId]?.visibleGeometry
56
+ const slot = eventSlot ?? resolvedConfig?.[panelId]?.[bp]?.slot
57
+ const slotRef = refs[SLOT_REFS[slot]]
58
+
59
+ if (!visibleGeometry || !slotRef) {
60
+ return
61
+ }
62
+ if (typeof mapProvider.isGeometryObscured !== 'function') {
63
+ return
64
+ }
65
+
66
+ const waitForPanel = () => {
67
+ const panelRect = slotRef.current?.getBoundingClientRect()
68
+
69
+ if (!panelRect || panelRect.width === 0 || panelRect.height === 0) {
70
+ // Not ready yet, check on the next animation frame
71
+ requestAnimationFrame(waitForPanel)
72
+ return
73
+ }
74
+
75
+ // Panel now exists and has size, safe to measure
76
+ if (!mapProvider.isGeometryObscured(visibleGeometry, panelRect)) {
77
+ return
78
+ }
79
+
80
+ if (isPointGeometry(visibleGeometry)) {
81
+ const center = getPointCoordinates(visibleGeometry)
82
+ if (center) {
83
+ mapProvider.setView({ center })
84
+ }
85
+ } else {
86
+ mapProvider.fitToBounds(visibleGeometry)
87
+ }
88
+ }
89
+
90
+ // Start waiting for panel to exist with a measurable size
91
+ requestAnimationFrame(waitForPanel)
92
+ }
93
+
94
+ eventBus.on(events.APP_PANEL_OPENED, handlePanelOpened)
95
+
96
+ return () => {
97
+ eventBus.off(events.APP_PANEL_OPENED, handlePanelOpened)
98
+ }
99
+ }, [mapProvider, eventBus])
100
+ }