@defra/interactive-map 0.0.12-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.
@@ -139,12 +139,57 @@
139
139
  flex-direction: column;
140
140
  position: absolute;
141
141
  gap: var(--divider-gap);
142
- top: var(--inset-offset-top);
142
+ top: var(--left-offset-top);
143
143
  }
144
144
 
145
- .im-o-app__inset .im-c-panel {
146
- height: 100%;
147
- max-height: var(--inset-max-height);
145
+ // ---------------------------------------------------
146
+ // Left: Buttons and panels
147
+ // ---------------------------------------------------
148
+
149
+ .im-o-app__left {
150
+ position: absolute;
151
+ display: flex;
152
+ flex-direction: column;
153
+ left: var(--primary-gap);
154
+ top: var(--left-offset-top);
155
+ bottom: var(--left-offset-bottom);
156
+
157
+ @media (prefers-reduced-motion: no-preference) {
158
+ transition: bottom 0.15s ease;
159
+ }
160
+ }
161
+
162
+ .im-o-app__left-top {
163
+ display: flex;
164
+ flex-direction: column;
165
+ flex: 0 0 auto;
166
+ align-items: flex-end;
167
+ position: relative;
168
+ gap: var(--divider-gap);
169
+
170
+ .im-c-panel {
171
+ position: absolute;
172
+ top: 0;
173
+ left: 0;
174
+ max-height: var(--left-top-panel-max-height);
175
+ }
176
+ }
177
+
178
+ .im-o-app__left-bottom {
179
+ display: flex;
180
+ flex-direction: column;
181
+ flex: 0 0 auto;
182
+ margin-top: auto;
183
+ align-items: flex-end;
184
+ position: relative;
185
+ gap: var(--divider-gap);
186
+
187
+ .im-c-panel {
188
+ position: absolute;
189
+ bottom: 0;
190
+ left: 0;
191
+ max-height: var(--left-bottom-panel-max-height);
192
+ }
148
193
  }
149
194
 
150
195
  // ---------------------------------------------------
@@ -164,7 +209,7 @@
164
209
  }
165
210
 
166
211
  // ---------------------------------------------------
167
- // Right: Buttons and inset panel
212
+ // Right: Buttons and panels
168
213
  // ---------------------------------------------------
169
214
 
170
215
  .im-o-app__right {
@@ -174,18 +219,87 @@
174
219
  right: var(--primary-gap);
175
220
  top: var(--right-offset-top);
176
221
  bottom: var(--right-offset-bottom);
222
+
223
+ @media (prefers-reduced-motion: no-preference) {
224
+ transition: bottom 0.15s ease;
225
+ }
177
226
  }
178
227
 
179
228
  .im-o-app__right-top {
180
229
  display: flex;
181
230
  flex-direction: column;
182
- flex-shrink: 0;
231
+ flex: 0 0 auto;
183
232
  align-items: flex-end;
233
+ position: relative;
184
234
  gap: var(--divider-gap);
235
+
236
+ .im-c-panel {
237
+ position: absolute;
238
+ top: 0;
239
+ right: 0;
240
+ max-height: var(--right-top-panel-max-height);
241
+ }
185
242
  }
186
243
 
187
244
  .im-o-app__right-bottom {
245
+ display: flex;
246
+ flex-direction: column;
247
+ flex: 0 0 auto;
188
248
  margin-top: auto;
249
+ align-items: flex-end;
250
+ position: relative;
251
+ gap: var(--divider-gap);
252
+
253
+ .im-c-panel {
254
+ position: absolute;
255
+ bottom: 0;
256
+ right: 0;
257
+ max-height: var(--right-bottom-panel-max-height);
258
+ }
259
+ }
260
+
261
+ // When both left sub-slots have panels, split the column proportionally
262
+ // so panels don't overlap. flex: 1 1 auto gives each sub-slot a share of the
263
+ // column; max-height: 100% caps the panel at its sub-slot height (not forced).
264
+ .im-o-app__left:has(.im-o-app__left-top > .im-c-panel:not([style*="display: none"])):has(.im-o-app__left-bottom > .im-c-panel:not([style*="display: none"])) {
265
+ gap: var(--divider-gap);
266
+
267
+ .im-o-app__left-top,
268
+ .im-o-app__left-bottom {
269
+ flex: 1 1 auto;
270
+ min-height: 0;
271
+ }
272
+
273
+ .im-o-app__left-bottom {
274
+ margin-top: 0;
275
+ justify-content: flex-end;
276
+ }
277
+
278
+ .im-o-app__left-top .im-c-panel,
279
+ .im-o-app__left-bottom .im-c-panel {
280
+ max-height: 100%;
281
+ }
282
+ }
283
+
284
+ // Same for right column
285
+ .im-o-app__right:has(.im-o-app__right-top > .im-c-panel:not([style*="display: none"])):has(.im-o-app__right-bottom > .im-c-panel:not([style*="display: none"])) {
286
+ gap: var(--divider-gap);
287
+
288
+ .im-o-app__right-top,
289
+ .im-o-app__right-bottom {
290
+ flex: 1 1 auto;
291
+ min-height: 0;
292
+ }
293
+
294
+ .im-o-app__right-bottom {
295
+ margin-top: 0;
296
+ justify-content: flex-end;
297
+ }
298
+
299
+ .im-o-app__right-top .im-c-panel,
300
+ .im-o-app__right-bottom .im-c-panel {
301
+ max-height: 100%;
302
+ }
189
303
  }
190
304
 
191
305
  // ---------------------------------------------------
@@ -301,10 +415,33 @@
301
415
  }
302
416
 
303
417
  .im-c-panel--inset {
304
- top: var(--inset-offset-top);
305
- left: var(--primary-gap);
418
+ inset: var(--modal-inset);
306
419
  max-width: calc(100% - (var(--primary-gap) * 2));
307
- max-height: calc(100% - var(--inset-offset-top) - var(--primary-gap));
420
+ max-height: var(--modal-max-height);
421
+ }
422
+
423
+ .im-c-panel--left-top,
424
+ .im-c-panel.im-c-panel--left-top-button {
425
+ inset: var(--left-offset-top) auto auto var(--primary-gap);
426
+ max-height: calc(100% - var(--left-offset-top) - var(--primary-gap));
427
+ }
428
+
429
+ .im-c-panel--left-bottom,
430
+ .im-c-panel.im-c-panel--left-bottom-button {
431
+ inset: auto auto var(--left-offset-bottom) var(--primary-gap);
432
+ max-height: calc(100% - var(--left-offset-bottom) - var(--primary-gap));
433
+ }
434
+
435
+ .im-c-panel--right-top,
436
+ .im-c-panel.im-c-panel--right-top-button {
437
+ inset: var(--right-offset-top) var(--primary-gap) auto auto;
438
+ max-height: calc(100% - var(--right-offset-top) - var(--primary-gap));
439
+ }
440
+
441
+ .im-c-panel--right-bottom,
442
+ .im-c-panel.im-c-panel--right-bottom-button {
443
+ inset: auto var(--primary-gap) var(--right-offset-bottom) auto;
444
+ max-height: calc(100% - var(--right-offset-bottom) - var(--primary-gap));
308
445
  }
309
446
 
310
447
  .im-c-panel--middle {
@@ -323,7 +460,9 @@
323
460
 
324
461
  [class*="im-c-panel--"][class*="-button"] { // Adjacent to button
325
462
  inset: var(--modal-inset);
463
+ max-height: var(--modal-max-height);
326
464
  }
465
+
327
466
  }
328
467
 
329
468
  // Mobile and tablet
@@ -4,7 +4,6 @@ import { useApp } from '../store/appContext.js'
4
4
  import { Panel } from '../components/Panel/Panel.jsx'
5
5
  import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
6
6
  import { allowedSlots } from './slots.js'
7
- import { stringToKebab } from '../../utils/stringToKebab.js'
8
7
 
9
8
  /**
10
9
  * Maps slot names to their corresponding layout refs.
@@ -16,12 +15,20 @@ export const getSlotRef = (slot, layoutRefs) => {
16
15
  'top-left': layoutRefs.topLeftColRef,
17
16
  'top-right': layoutRefs.topRightColRef,
18
17
  inset: layoutRefs.insetRef,
18
+ 'left-top': layoutRefs.leftTopRef,
19
+ 'left-bottom': layoutRefs.leftBottomRef,
19
20
  middle: layoutRefs.middleRef,
20
- bottom: layoutRefs.bottomRef,
21
+ 'right-top': layoutRefs.rightTopRef,
21
22
  'right-bottom': layoutRefs.rightBottomRef,
23
+ bottom: layoutRefs.bottomRef,
22
24
  actions: layoutRefs.actionsRef,
23
25
  modal: layoutRefs.modalRef
24
26
  }
27
+ if (slot?.endsWith('-button')) {
28
+ const el = document.querySelector(`[data-button-slot="${slot}"]`)
29
+ return el ? { current: el } : null
30
+ }
31
+
25
32
  return slotRefMap[slot] || null
26
33
  }
27
34
 
@@ -79,7 +86,7 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, allowedModal
79
86
  }
80
87
 
81
88
  // 2. Slot Validation
82
- const isNextToButton = `${stringToKebab(panelId)}-button` === targetSlot
89
+ const isNextToButton = targetSlot.endsWith('-button')
83
90
  const isSlotAllowed = allowedSlots.panel.includes(targetSlot) || isNextToButton
84
91
 
85
92
  if (!isSlotAllowed) {
@@ -292,6 +292,18 @@ describe('HtmlElementHost', () => {
292
292
  expect(getSlotRef('unknown-slot', {})).toBeNull()
293
293
  })
294
294
 
295
+ test('getSlotRef returns wrapped element for button slot when element exists', () => {
296
+ const el = document.createElement('div')
297
+ el.dataset.buttonSlot = 'my-panel-button'
298
+ document.body.appendChild(el)
299
+ expect(getSlotRef('my-panel-button', {})).toEqual({ current: el })
300
+ el.remove()
301
+ })
302
+
303
+ test('getSlotRef returns null for button slot when no element found', () => {
304
+ expect(getSlotRef('nonexistent-button', {})).toBeNull()
305
+ })
306
+
295
307
  it('does not append child if slotRef exists but current is null', () => {
296
308
  // 1. Setup refs where the slot exists in the map but the DOM node (current) is null
297
309
  const incompleteRefs = {
@@ -24,7 +24,7 @@ export const SlotRenderer = ({ slot }) => {
24
24
  <>
25
25
  {slot === 'actions'
26
26
  ? (
27
- <Actions slot={slot}>
27
+ <Actions slot='actions'>
28
28
  {slotItems.map(item => item.element)}
29
29
  </Actions>
30
30
  )
@@ -1,6 +1,5 @@
1
1
  // src/core/renderers/mapPanels.js
2
2
  import React from 'react'
3
- import { stringToKebab } from '../../utils/stringToKebab.js'
4
3
  import { withPluginContexts } from './pluginWrapper.js'
5
4
  import { Panel } from '../components/Panel/Panel.jsx'
6
5
  import { allowedSlots } from './slots.js'
@@ -12,7 +11,7 @@ import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.
12
11
  * and ensures only the topmost modal panel is shown.
13
12
  */
14
13
  const isPanelVisible = (panelId, config, bpConfig, { targetSlot, slot, mode, isFullscreen, allowedModalPanelId }) => {
15
- const isNextToButton = `${stringToKebab(panelId)}-button` === targetSlot
14
+ const isNextToButton = targetSlot.endsWith('-button')
16
15
  if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) {
17
16
  return false
18
17
  }
@@ -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,
33
- layoutSlots.RIGHT_BOTTOM,
34
+ layoutSlots.INSET, // Deprecate
35
+ layoutSlots.LEFT_TOP,
36
+ layoutSlots.LEFT_BOTTOM,
34
37
  layoutSlots.MIDDLE,
35
- layoutSlots.BOTTOM,
36
- layoutSlots.ACTIONS,
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,9 +20,13 @@ export const AppProvider = ({ options, children }) => {
20
20
  topLeftColRef: useRef(null),
21
21
  topRightColRef: useRef(null),
22
22
  insetRef: useRef(null),
23
+ leftRef: useRef(null),
24
+ leftTopRef: useRef(null),
25
+ leftBottomRef: useRef(null),
26
+ middleRef: useRef(null),
23
27
  rightRef: useRef(null),
28
+ rightTopRef: useRef(null),
24
29
  rightBottomRef: useRef(null),
25
- middleRef: useRef(null),
26
30
  bottomRef: useRef(null),
27
31
  footerRef: useRef(null),
28
32
  actionsRef: useRef(null),
@@ -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
  })