@defra/interactive-map 0.0.11-alpha → 0.0.14-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 (58) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/plugins/plugin-descriptor.md +37 -0
  6. package/package.json +15 -6
  7. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  8. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/src/events.js +4 -14
  10. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +1 -3
  11. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  12. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  13. package/plugins/interact/src/InteractInit.jsx +28 -6
  14. package/plugins/interact/src/InteractInit.test.js +19 -5
  15. package/plugins/interact/src/events.js +17 -15
  16. package/plugins/interact/src/events.test.js +25 -16
  17. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  18. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  19. package/plugins/search/src/events/fetchSuggestions.js +9 -6
  20. package/providers/beta/esri/dist/css/index.css +4 -0
  21. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  22. package/providers/beta/esri/src/esriProvider.js +19 -3
  23. package/providers/beta/esri/src/esriProvider.scss +5 -0
  24. package/providers/beta/esri/src/mapEvents.js +34 -3
  25. package/providers/beta/esri/src/utils/coords.js +1 -0
  26. package/providers/beta/esri/src/utils/spatial.js +47 -1
  27. package/providers/beta/esri/src/utils/spatial.test.js +55 -0
  28. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  29. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  30. package/providers/maplibre/src/maplibreProvider.js +12 -1
  31. package/providers/maplibre/src/maplibreProvider.test.js +14 -1
  32. package/providers/maplibre/src/utils/spatial.js +40 -0
  33. package/providers/maplibre/src/utils/spatial.test.js +35 -0
  34. package/src/App/components/MapButton/MapButton.jsx +1 -0
  35. package/src/App/components/Panel/Panel.jsx +14 -13
  36. package/src/App/components/Viewport/MapController.jsx +4 -0
  37. package/src/App/hooks/useLayoutMeasurements.js +37 -20
  38. package/src/App/hooks/useLayoutMeasurements.test.js +38 -6
  39. package/src/App/hooks/useMarkersAPI.js +5 -3
  40. package/src/App/hooks/useModalPanelBehaviour.js +91 -10
  41. package/src/App/hooks/useModalPanelBehaviour.test.js +185 -53
  42. package/src/App/hooks/useVisibleGeometry.js +100 -0
  43. package/src/App/hooks/useVisibleGeometry.test.js +331 -0
  44. package/src/App/layout/Layout.jsx +13 -5
  45. package/src/App/layout/layout.module.scss +149 -13
  46. package/src/App/renderer/HtmlElementHost.jsx +10 -2
  47. package/src/App/renderer/HtmlElementHost.test.jsx +12 -0
  48. package/src/App/renderer/SlotRenderer.jsx +1 -1
  49. package/src/App/renderer/mapPanels.js +1 -2
  50. package/src/App/renderer/pluginWrapper.js +3 -2
  51. package/src/App/renderer/slots.js +12 -6
  52. package/src/App/store/AppProvider.jsx +6 -1
  53. package/src/App/store/appDispatchMiddleware.js +19 -0
  54. package/src/App/store/appDispatchMiddleware.test.js +56 -0
  55. package/src/InteractiveMap/InteractiveMap.js +3 -3
  56. package/src/types.js +9 -0
  57. package/src/utils/getSafeZoneInset.js +12 -9
  58. package/src/utils/getSafeZoneInset.test.js +102 -58
@@ -6,9 +6,11 @@ export const layoutSlots = Object.freeze({
6
6
  TOP_MIDDLE: 'top-middle',
7
7
  TOP_RIGHT: 'top-right',
8
8
  INSET: 'inset',
9
+ LEFT_TOP: 'left-top',
10
+ LEFT_BOTTOM: 'left-bottom',
11
+ MIDDLE: 'middle',
9
12
  RIGHT_TOP: 'right-top',
10
13
  RIGHT_BOTTOM: 'right-bottom',
11
- MIDDLE: 'middle',
12
14
  FOOTER_RIGHT: 'footer-right',
13
15
  BOTTOM: 'bottom',
14
16
  ACTIONS: 'actions',
@@ -29,17 +31,21 @@ export const allowedSlots = Object.freeze({
29
31
  panel: [
30
32
  layoutSlots.SIDE,
31
33
  layoutSlots.BANNER,
32
- layoutSlots.INSET,
34
+ layoutSlots.INSET, // Deprecate
35
+ layoutSlots.LEFT_TOP,
36
+ layoutSlots.LEFT_BOTTOM,
33
37
  layoutSlots.MIDDLE,
34
- layoutSlots.BOTTOM,
35
- layoutSlots.ACTIONS,
36
- layoutSlots.DRAWER,
37
- layoutSlots.MODAL
38
+ layoutSlots.RIGHT_TOP,
39
+ layoutSlots.RIGHT_BOTTOM,
40
+ layoutSlots.BOTTOM, // Typicaly on mobile
41
+ layoutSlots.MODAL // Internal only
38
42
  ],
39
43
  button: [
40
44
  layoutSlots.TOP_LEFT,
41
45
  layoutSlots.TOP_MIDDLE,
42
46
  layoutSlots.TOP_RIGHT,
47
+ layoutSlots.LEFT_TOP,
48
+ layoutSlots.LEFT_BOTTOM,
43
49
  layoutSlots.RIGHT_TOP,
44
50
  layoutSlots.RIGHT_BOTTOM,
45
51
  layoutSlots.ACTIONS
@@ -20,8 +20,13 @@ export const AppProvider = ({ options, children }) => {
20
20
  topLeftColRef: useRef(null),
21
21
  topRightColRef: useRef(null),
22
22
  insetRef: useRef(null),
23
- rightRef: useRef(null),
23
+ leftRef: useRef(null),
24
+ leftTopRef: useRef(null),
25
+ leftBottomRef: useRef(null),
24
26
  middleRef: useRef(null),
27
+ rightRef: useRef(null),
28
+ rightTopRef: useRef(null),
29
+ rightBottomRef: useRef(null),
25
30
  bottomRef: useRef(null),
26
31
  footerRef: useRef(null),
27
32
  actionsRef: useRef(null),
@@ -1,5 +1,7 @@
1
1
  // src/App/store/dispatchMiddleware.js
2
2
  import { EVENTS as events } from '../../config/events.js'
3
+ import { defaultPanelConfig } from '../../config/appConfig.js'
4
+ import { deepMerge } from '../../utils/deepMerge.js'
3
5
 
4
6
  /**
5
7
  * Determines which panels were implicitly closed when opening a new panel
@@ -78,4 +80,21 @@ export function handleActionSideEffects (action, previousState, panelConfig, eve
78
80
  eventBus.emit(events.APP_PANEL_OPENED, { panelId, props })
79
81
  })
80
82
  }
83
+
84
+ if (type === 'ADD_PANEL') {
85
+ const { id, config } = payload
86
+ const mergedConfig = deepMerge(defaultPanelConfig, config)
87
+ const bpConfig = mergedConfig[previousState.breakpoint]
88
+ if (bpConfig?.open) {
89
+ queueMicrotask(() => {
90
+ const slot = bpConfig.slot
91
+ const { visibleGeometry } = mergedConfig
92
+ const eventPayload = { panelId: id, slot }
93
+ if (visibleGeometry) {
94
+ eventPayload.visibleGeometry = visibleGeometry
95
+ }
96
+ eventBus.emit(events.APP_PANEL_OPENED, eventPayload)
97
+ })
98
+ }
99
+ }
81
100
  }
@@ -177,4 +177,60 @@ describe('appDispatchMiddleware', () => {
177
177
  )
178
178
  })
179
179
  })
180
+
181
+ describe('ADD_PANEL', () => {
182
+ it('emits APP_PANEL_OPENED with slot when panel opens by default', async () => {
183
+ run(
184
+ { type: 'ADD_PANEL', payload: { id: 'newPanel', config: {} } },
185
+ { breakpoint: 'desktop' }
186
+ )
187
+
188
+ await flushMicrotasks()
189
+
190
+ expect(eventBus.emit).toHaveBeenCalledWith(
191
+ events.APP_PANEL_OPENED,
192
+ { panelId: 'newPanel', slot: 'inset' }
193
+ )
194
+ })
195
+
196
+ it('emits APP_PANEL_OPENED with visibleGeometry when provided in config', async () => {
197
+ const visibleGeometry = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 2] }, properties: {} }
198
+ run(
199
+ { type: 'ADD_PANEL', payload: { id: 'geoPanel', config: { visibleGeometry } } },
200
+ { breakpoint: 'desktop' }
201
+ )
202
+
203
+ await flushMicrotasks()
204
+
205
+ expect(eventBus.emit).toHaveBeenCalledWith(
206
+ events.APP_PANEL_OPENED,
207
+ { panelId: 'geoPanel', slot: 'inset', visibleGeometry }
208
+ )
209
+ })
210
+
211
+ it('does not emit APP_PANEL_OPENED when breakpoint config sets open: false', async () => {
212
+ run(
213
+ { type: 'ADD_PANEL', payload: { id: 'hiddenPanel', config: { desktop: { open: false } } } },
214
+ { breakpoint: 'desktop' }
215
+ )
216
+
217
+ await flushMicrotasks()
218
+
219
+ expect(eventBus.emit).not.toHaveBeenCalled()
220
+ })
221
+
222
+ it('emits APP_PANEL_OPENED with slot for mobile breakpoint', async () => {
223
+ run(
224
+ { type: 'ADD_PANEL', payload: { id: 'mobilePanel', config: {} } },
225
+ { breakpoint: 'mobile' }
226
+ )
227
+
228
+ await flushMicrotasks()
229
+
230
+ expect(eventBus.emit).toHaveBeenCalledWith(
231
+ events.APP_PANEL_OPENED,
232
+ { panelId: 'mobilePanel', slot: 'bottom' }
233
+ )
234
+ })
235
+ })
180
236
  })
@@ -408,10 +408,10 @@ export default class InteractiveMap {
408
408
  /**
409
409
  * Fit the map view to a bounding box or GeoJSON geometry, respecting the safe zone padding.
410
410
  *
411
- * @param {[number, number, number, number] | object} bbox - Bounds as [west, south, east, north] or [minX, minY, maxX, maxY] depending on the crs, or a GeoJSON Feature, FeatureCollection, or geometry.
411
+ * @param {[number, number, number, number] | object} target - Bounds as [west, south, east, north] or [minX, minY, maxX, maxY] depending on the crs, or a GeoJSON Feature, FeatureCollection, or geometry.
412
412
  */
413
- fitToBounds (bbox) {
414
- this.eventBus.emit(events.MAP_FIT_TO_BOUNDS, bbox)
413
+ fitToBounds (target) {
414
+ this.eventBus.emit(events.MAP_FIT_TO_BOUNDS, target)
415
415
  }
416
416
 
417
417
  /**
package/src/types.js CHANGED
@@ -247,6 +247,10 @@
247
247
  *
248
248
  * @property {() => void} [clearHighlightedLabel]
249
249
  * @experimental Clear any highlighted label.
250
+ *
251
+ * @property {(geojson: object, panelRect: DOMRect) => boolean} [isGeometryObscured]
252
+ * Returns true if the geometry's screen bounding box overlaps the given panel element rectangle.
253
+ * Used internally by useVisibleGeometry to decide whether to pan/zoom when a panel opens.
250
254
  */
251
255
 
252
256
  /**
@@ -373,6 +377,11 @@
373
377
  *
374
378
  * @property {PanelBreakpointConfig} tablet
375
379
  * Tablet breakpoint configuration.
380
+ *
381
+ * @property {object} [visibleGeometry]
382
+ * GeoJSON Feature, FeatureCollection, or geometry to keep visible when this panel opens.
383
+ * If any part of the geometry's bounding box is obscured by the safe zone after the panel opens,
384
+ * the map automatically adjusts: Point or MultiPoint geometry routes to setView(), all other types to fitToBounds().
376
385
  */
377
386
 
378
387
  /**
@@ -10,6 +10,7 @@
10
10
  * @param {Object} refs - React refs for the key layout elements.
11
11
  * @param {React.RefObject} refs.mainRef - The main content area.
12
12
  * @param {React.RefObject} refs.insetRef - The inset panel (e.g. search results).
13
+ * @param {React.RefObject} refs.leftRef - The left-hand button column.
13
14
  * @param {React.RefObject} refs.rightRef - The right-hand button column.
14
15
  * @param {React.RefObject} refs.actionsRef - The bottom action bar.
15
16
  * @param {React.RefObject} refs.footerRef - The footer (logo, copyright etc).
@@ -19,29 +20,31 @@
19
20
  export const getSafeZoneInset = ({
20
21
  mainRef,
21
22
  insetRef,
23
+ leftRef,
22
24
  rightRef,
23
25
  actionsRef,
24
26
  footerRef
25
27
  }) => {
26
- const refs = [mainRef, insetRef, rightRef, actionsRef, footerRef]
28
+ const refs = [mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef]
27
29
 
28
30
  if (refs.some(ref => !ref.current)) {
29
31
  return undefined
30
32
  }
31
33
 
32
- const [main, inset, right, actions, footer] = refs.map(ref => ref.current)
34
+ const [main, inset, left, right, actions, footer] = refs.map(ref => ref.current)
33
35
 
34
36
  const root = document.documentElement
35
37
  const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
36
38
 
37
39
  // === Safe area logic ===
38
40
  const availableHeight = actions.offsetTop - inset.offsetTop - dividerGap
39
- const rightOffset = inset.offsetLeft + right.offsetWidth + dividerGap
40
- const availableWidth = main.offsetWidth - rightOffset * 2
41
- const insetOverlapWidth = inset.offsetWidth - rightOffset + inset.offsetLeft
41
+ const leftOffset = left.offsetLeft + left.offsetWidth + dividerGap
42
+ const rightOffset = left.offsetLeft + right.offsetWidth + dividerGap
43
+ const availableWidth = main.offsetWidth - (leftOffset + rightOffset)
44
+ const insetOverlapWidth = inset.offsetWidth - leftOffset + left.offsetLeft
42
45
  const isLandscape = availableWidth - insetOverlapWidth > availableHeight - inset.offsetHeight
43
- const topOffset = inset.offsetTop + (!isLandscape && inset.offsetHeight > 0 ? inset.offsetHeight + dividerGap : 0)
44
- const leftOffset = isLandscape ? inset.offsetWidth + inset.offsetLeft + dividerGap : rightOffset
46
+ const topOffset = left.offsetTop + (!isLandscape && inset.offsetHeight > 0 ? inset.offsetHeight + dividerGap : 0)
47
+ const combinedLeftOffset = isLandscape ? Math.max(inset.offsetWidth, left.offsetWidth) + left.offsetLeft + dividerGap : rightOffset
45
48
  const actionsOffset = main.offsetHeight - actions.offsetTop
46
49
  const footerOffset = main.offsetHeight - footer.offsetTop
47
50
 
@@ -49,8 +52,8 @@ export const getSafeZoneInset = ({
49
52
  const hasRoom = insetOverlapWidth < availableWidth / RATIO && inset.offsetHeight < availableHeight / RATIO
50
53
 
51
54
  const top = hasRoom ? inset.offsetTop : topOffset
52
- const left = main.offsetLeft + (hasRoom ? rightOffset : Math.max(leftOffset, rightOffset))
55
+ const combinedLeft = main.offsetLeft + (hasRoom ? rightOffset : combinedLeftOffset)
53
56
  const bottom = Math.max(actionsOffset, footerOffset) + dividerGap
54
57
 
55
- return { top, right: rightOffset, left, bottom }
58
+ return { top, right: rightOffset, left: combinedLeft, bottom }
56
59
  }
@@ -1,7 +1,7 @@
1
1
  import { getSafeZoneInset } from './getSafeZoneInset'
2
2
 
3
3
  describe('getSafeZoneInset', () => {
4
- let mainRef, insetRef, rightRef, footerRef, actionsRef
4
+ let mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef
5
5
  let originalGetComputedStyle
6
6
 
7
7
  beforeAll(() => { originalGetComputedStyle = window.getComputedStyle })
@@ -10,80 +10,124 @@ describe('getSafeZoneInset', () => {
10
10
  beforeEach(() => {
11
11
  mainRef = { current: { offsetWidth: 800, offsetHeight: 600, offsetLeft: 0 } }
12
12
  insetRef = { current: { offsetWidth: 100, offsetHeight: 50, offsetTop: 50, offsetLeft: 20 } }
13
- rightRef = { current: { offsetWidth: 50, offsetLeft: 0 } }
14
- footerRef = { current: { offsetTop: 550 } }
13
+ leftRef = { current: { offsetWidth: 50, offsetLeft: 20, offsetTop: 10 } }
14
+ rightRef = { current: { offsetWidth: 50, offsetLeft: 730 } }
15
15
  actionsRef = { current: { offsetTop: 520 } }
16
+ footerRef = { current: { offsetTop: 550 } }
16
17
 
17
- // Mock CSS var --divider-gap = 10
18
+ // CSS var mock
18
19
  window.getComputedStyle = jest.fn().mockReturnValue({ getPropertyValue: () => '10' })
19
20
  })
20
21
 
21
- const runScenario = ({ isLandscape, insetHeight }) => {
22
- insetRef.current.offsetHeight = insetHeight
22
+ it('returns undefined if any ref.current is null', () => {
23
+ const result = getSafeZoneInset({
24
+ mainRef: { current: null },
25
+ insetRef,
26
+ leftRef,
27
+ rightRef,
28
+ actionsRef,
29
+ footerRef
30
+ })
31
+ expect(result).toBeUndefined()
32
+ })
23
33
 
24
- // Manipulate dimensions to influence landscape heuristic
25
- if (isLandscape) {
26
- mainRef.current.offsetWidth = 1000
27
- insetRef.current.offsetWidth = 400
28
- } else {
29
- mainRef.current.offsetWidth = 600
30
- insetRef.current.offsetWidth = 100
31
- }
34
+ it('portrait mode shifts inset below itself when it does NOT have enough vertical room', () => {
35
+ // Mock layout
36
+ mainRef.current.offsetWidth = 200
37
+ mainRef.current.offsetHeight = 200
38
+ insetRef.current.offsetWidth = 100
39
+ insetRef.current.offsetHeight = 50
40
+ insetRef.current.offsetTop = 50
41
+ insetRef.current.offsetLeft = 20
32
42
 
33
- return getSafeZoneInset({ mainRef, insetRef, rightRef, footerRef, actionsRef })
34
- }
43
+ leftRef.current.offsetWidth = 50
44
+ leftRef.current.offsetLeft = 10
45
+ leftRef.current.offsetTop = 10
35
46
 
36
- it('topOffset adds 0 when portrait and height = 0', () => {
37
- const result = runScenario({ isLandscape: false, insetHeight: 0 })
38
- expect(result.top).toBe(insetRef.current.offsetTop)
39
- })
47
+ rightRef.current.offsetWidth = 50
48
+ rightRef.current.offsetLeft = 140
40
49
 
41
- it('landscape returns left = rightOffset when there is enough room', () => {
42
- const result = runScenario({ isLandscape: true, insetHeight: 50 })
43
- expect(result.top).toBe(insetRef.current.offsetTop)
44
- expect(result.left).toBe(80) // rightOffset = 20 + 50 + 10
45
- expect(result.left).toBe(result.right) // left equals returned rightOffset
46
- })
50
+ actionsRef.current.offsetTop = 150
51
+ footerRef.current.offsetTop = 180
47
52
 
48
- it('landscape returns left = rightOffset even when inset height = 0', () => {
49
- const result = runScenario({ isLandscape: true, insetHeight: 0 })
50
- expect(result.top).toBe(insetRef.current.offsetTop)
51
- expect(result.left).toBe(80)
52
- expect(result.left).toBe(result.right)
53
+ const dividerGap = 10
54
+
55
+ const result = getSafeZoneInset({ mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef })
56
+
57
+ // Compute expected values exactly as function would
58
+ const availableHeight = actionsRef.current.offsetTop - insetRef.current.offsetTop - dividerGap // 150 - 50 - 10 = 90
59
+ const leftOffset = leftRef.current.offsetLeft + leftRef.current.offsetWidth + dividerGap // 10 + 50 + 10 = 70
60
+ const rightOffset = leftRef.current.offsetLeft + rightRef.current.offsetWidth + dividerGap // 10 + 50 + 10 = 70
61
+ const availableWidth = mainRef.current.offsetWidth - (leftOffset + rightOffset) // 200 - (70+70) = 60
62
+ const insetOverlapWidth = insetRef.current.offsetWidth - leftOffset + leftRef.current.offsetLeft // 100 - 70 + 10 = 40
63
+ const isLandscape = availableWidth - insetOverlapWidth > availableHeight - insetRef.current.offsetHeight // 60-40 > 90-50 => 20>40 false
64
+
65
+ const topOffset = leftRef.current.offsetTop + (!isLandscape && insetRef.current.offsetHeight > 0 ? insetRef.current.offsetHeight + dividerGap : 0) // 10 + 50 +10 = 70
66
+ const combinedLeftOffset = isLandscape ? Math.max(insetRef.current.offsetWidth, leftRef.current.offsetWidth) + leftRef.current.offsetLeft + dividerGap : rightOffset // isLandscape=false -> 70
67
+ const actionsOffset = mainRef.current.offsetHeight - actionsRef.current.offsetTop // 200-150=50
68
+ const footerOffset = mainRef.current.offsetHeight - footerRef.current.offsetTop // 200-180=20
69
+ const hasRoom = insetOverlapWidth < availableWidth / 2 && insetRef.current.offsetHeight < availableHeight / 2 // 40 < 60/2? 40<30 false
70
+
71
+ const expectedTop = hasRoom ? insetRef.current.offsetTop : topOffset // false -> topOffset = 70
72
+ const expectedLeft = mainRef.current.offsetLeft + (hasRoom ? rightOffset : combinedLeftOffset) // 0+70=70
73
+ const expectedRight = rightOffset // 70
74
+ const expectedBottom = Math.max(actionsOffset, footerOffset) + dividerGap // max(50,20)+10 = 60
75
+
76
+ expect(result.top).toBe(expectedTop)
77
+ expect(result.left).toBe(expectedLeft)
78
+ expect(result.right).toBe(expectedRight)
79
+ expect(result.bottom).toBe(expectedBottom)
53
80
  })
54
81
 
55
- it('portrait shifts inset below itself when it does NOT have enough vertical room', () => {
56
- // Force a portrait overflow case
57
- mainRef.current.offsetWidth = 200
58
- insetRef.current.offsetWidth = 100
82
+ it('landscape mode places inset beside panel when enough room', () => {
83
+ mainRef.current.offsetWidth = 1000
84
+ insetRef.current.offsetWidth = 200
59
85
  insetRef.current.offsetHeight = 50
60
- insetRef.current.offsetTop = 50
61
- window.getComputedStyle = jest.fn().mockReturnValue({ getPropertyValue: () => '10' })
62
86
 
63
- const result = getSafeZoneInset({ mainRef, insetRef, rightRef, footerRef, actionsRef })
87
+ const result = getSafeZoneInset({ mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef })
88
+
89
+ const dividerGap = 10
90
+ const leftOffset = leftRef.current.offsetLeft + leftRef.current.offsetWidth + dividerGap
91
+ const rightOffset = leftRef.current.offsetLeft + rightRef.current.offsetWidth + dividerGap
92
+ const availableWidth = mainRef.current.offsetWidth - (leftOffset + rightOffset)
93
+ const insetOverlapWidth = insetRef.current.offsetWidth - leftOffset + leftRef.current.offsetLeft
94
+ const availableHeight = actionsRef.current.offsetTop - insetRef.current.offsetTop - dividerGap
95
+ const isLandscape = availableWidth - insetOverlapWidth > availableHeight - insetRef.current.offsetHeight
96
+
97
+ const topOffset = leftRef.current.offsetTop + (!isLandscape && insetRef.current.offsetHeight > 0 ? insetRef.current.offsetHeight + dividerGap : 0)
98
+ const combinedLeftOffset = isLandscape ? Math.max(insetRef.current.offsetWidth, leftRef.current.offsetWidth) + leftRef.current.offsetLeft + dividerGap : rightOffset
99
+ const actionsOffset = mainRef.current.offsetHeight - actionsRef.current.offsetTop
100
+ const footerOffset = mainRef.current.offsetHeight - footerRef.current.offsetTop
101
+ const hasRoom = insetOverlapWidth < availableWidth / 2 && insetRef.current.offsetHeight < availableHeight / 2
102
+ const top = hasRoom ? insetRef.current.offsetTop : topOffset
103
+ const combinedLeft = mainRef.current.offsetLeft + (hasRoom ? rightOffset : combinedLeftOffset)
104
+ const bottom = Math.max(actionsOffset, footerOffset) + dividerGap
64
105
 
65
- // topOffset = 50 + 50 + 10 = 110
66
- expect(result.top).toBe(110)
67
- // left = rightOffset = 20 + 50 + 10 = 80
68
- expect(result.left).toBe(80)
69
- expect(result.right).toBe(80)
106
+ expect(result.top).toBe(top)
107
+ expect(result.left).toBe(combinedLeft)
108
+ expect(result.right).toBe(rightOffset)
109
+ expect(result.bottom).toBe(bottom)
70
110
  })
71
111
 
72
- /**
73
- * Test to ensure coverage for the safety guardrail (Line 29).
74
- * Validates that the function returns undefined if React refs are
75
- * not yet attached to DOM elements.
76
- */
77
- it('returns undefined if any ref.current is null (unattached)', () => {
78
- const unattachedRefs = {
79
- mainRef: { current: null },
80
- insetRef: { current: null },
81
- rightRef: { current: null },
82
- actionsRef: { current: null },
83
- footerRef: { current: null }
84
- }
112
+ it('portrait mode with zero inset height leaves top unchanged', () => {
113
+ insetRef.current.offsetHeight = 0
114
+ mainRef.current.offsetWidth = 500
115
+ insetRef.current.offsetWidth = 100
85
116
 
86
- const result = getSafeZoneInset(unattachedRefs)
87
- expect(result).toBeUndefined()
117
+ const result = getSafeZoneInset({ mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef })
118
+ expect(result.top).toBe(insetRef.current.offsetTop)
119
+ })
120
+
121
+ it('calculates correct bottom using max of actions and footer offsets', () => {
122
+ mainRef.current.offsetHeight = 600
123
+ actionsRef.current.offsetTop = 500
124
+ footerRef.current.offsetTop = 550
125
+
126
+ const result = getSafeZoneInset({ mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef })
127
+
128
+ const dividerGap = 10
129
+ const expectedBottom = Math.max(mainRef.current.offsetHeight - actionsRef.current.offsetTop,
130
+ mainRef.current.offsetHeight - footerRef.current.offsetTop) + dividerGap
131
+ expect(result.bottom).toBe(expectedBottom)
88
132
  })
89
133
  })