@defra/interactive-map 0.0.12-alpha → 0.0.15-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 (43) 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/package.json +9 -4
  5. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  6. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  7. package/plugins/beta/datasets/src/manifest.js +4 -4
  8. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  9. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  10. package/plugins/beta/map-styles/src/manifest.js +2 -2
  11. package/plugins/search/dist/css/index.css +1 -1
  12. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  13. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  14. package/plugins/search/src/events/fetchSuggestions.js +10 -7
  15. package/plugins/search/src/events/fetchSuggestions.test.js +4 -4
  16. package/plugins/search/src/search.scss +8 -3
  17. package/providers/beta/esri/dist/css/index.css +4 -0
  18. package/providers/beta/esri/src/esriProvider.scss +5 -0
  19. package/src/App/components/MapButton/MapButton.jsx +1 -0
  20. package/src/App/components/Panel/Panel.jsx +14 -13
  21. package/src/App/components/Panel/Panel.module.scss +1 -0
  22. package/src/App/hooks/useLayoutMeasurements.js +31 -23
  23. package/src/App/hooks/useLayoutMeasurements.test.js +39 -10
  24. package/src/App/hooks/useModalPanelBehaviour.js +85 -21
  25. package/src/App/hooks/useModalPanelBehaviour.test.js +126 -18
  26. package/src/App/hooks/useVisibleGeometry.js +7 -13
  27. package/src/App/hooks/useVisibleGeometry.test.js +72 -47
  28. package/src/App/layout/Layout.jsx +11 -6
  29. package/src/App/layout/Layout.test.jsx +0 -1
  30. package/src/App/layout/layout.module.scss +83 -10
  31. package/src/App/renderer/HtmlElementHost.jsx +10 -4
  32. package/src/App/renderer/HtmlElementHost.test.jsx +32 -11
  33. package/src/App/renderer/SlotRenderer.jsx +1 -1
  34. package/src/App/renderer/mapPanels.js +1 -2
  35. package/src/App/renderer/mapPanels.test.js +3 -3
  36. package/src/App/renderer/slotHelpers.js +2 -2
  37. package/src/App/renderer/slotHelpers.test.js +3 -3
  38. package/src/App/renderer/slots.js +11 -8
  39. package/src/App/store/AppProvider.jsx +5 -2
  40. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  41. package/src/config/appConfig.js +4 -4
  42. package/src/utils/getSafeZoneInset.js +139 -39
  43. package/src/utils/getSafeZoneInset.test.js +301 -81
@@ -31,14 +31,8 @@ export const getPointCoordinates = (geojson) => {
31
31
  return null
32
32
  }
33
33
 
34
- const SLOT_REFS = {
35
- inset: 'insetRef',
36
- bottom: 'bottomRef',
37
- side: 'sideRef'
38
- }
39
-
40
34
  export const useVisibleGeometry = () => {
41
- const { mapProvider, eventBus } = useConfig()
35
+ const { id, mapProvider, eventBus } = useConfig()
42
36
  const { layoutRefs, panelConfig, panelRegistry, breakpoint } = useApp()
43
37
 
44
38
  const latestRef = useRef({ layoutRefs, panelConfig, panelRegistry, breakpoint })
@@ -49,14 +43,13 @@ export const useVisibleGeometry = () => {
49
43
  return undefined
50
44
  }
51
45
 
52
- const handlePanelOpened = ({ panelId, slot: eventSlot, visibleGeometry: eventVisibleGeometry }) => {
53
- const { panelConfig: config, panelRegistry: registry, layoutRefs: refs, breakpoint: bp } = latestRef.current
46
+ const handlePanelOpened = ({ panelId, visibleGeometry: eventVisibleGeometry }) => {
47
+ const { panelConfig: config, panelRegistry: registry } = latestRef.current
54
48
  const resolvedConfig = config?.[panelId] ? config : (registry?.getPanelConfig() ?? config)
55
49
  const visibleGeometry = eventVisibleGeometry ?? resolvedConfig?.[panelId]?.visibleGeometry
56
- const slot = eventSlot ?? resolvedConfig?.[panelId]?.[bp]?.slot
57
- const slotRef = refs[SLOT_REFS[slot]]
50
+ const panel = layoutRefs.appContainerRef.current?.querySelector(`#${id}-panel-${panelId}`)
58
51
 
59
- if (!visibleGeometry || !slotRef) {
52
+ if (!visibleGeometry) {
60
53
  return
61
54
  }
62
55
  if (typeof mapProvider.isGeometryObscured !== 'function') {
@@ -64,7 +57,8 @@ export const useVisibleGeometry = () => {
64
57
  }
65
58
 
66
59
  const waitForPanel = () => {
67
- const panelRect = slotRef.current?.getBoundingClientRect()
60
+ if (!panel) { return }
61
+ const panelRect = panel.getBoundingClientRect()
68
62
 
69
63
  if (!panelRect || panelRect.width === 0 || panelRect.height === 0) {
70
64
  // Not ready yet, check on the next animation frame
@@ -10,8 +10,16 @@ const pointFeature = { type: 'Feature', geometry: { type: 'Point', coordinates:
10
10
  const multiPointFeature = { type: 'Feature', geometry: { type: 'MultiPoint', coordinates: [[1, 51], [2, 52]] }, properties: {} }
11
11
  const polygonFeature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} }
12
12
 
13
- const insetPanelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
14
- const bottomPanelRect = { left: 0, top: 500, right: 1000, bottom: 800, width: 1000, height: 300 }
13
+ const APP_ID = 'test'
14
+ const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
15
+
16
+ // Creates a panel DOM element with id matching what useVisibleGeometry queries.
17
+ const makePanelEl = (panelId, rect = panelRect) => {
18
+ const el = document.createElement('div')
19
+ el.id = `${APP_ID}-panel-${panelId}`
20
+ el.getBoundingClientRect = jest.fn(() => rect)
21
+ return el
22
+ }
15
23
 
16
24
  const setup = (overrides = {}) => {
17
25
  const capturedHandlers = {}
@@ -27,19 +35,18 @@ const setup = (overrides = {}) => {
27
35
  ...overrides.eventBus
28
36
  }
29
37
 
30
- const insetEl = document.createElement('div')
31
- insetEl.getBoundingClientRect = jest.fn(() => insetPanelRect)
32
- const bottomEl = document.createElement('div')
33
- bottomEl.getBoundingClientRect = jest.fn(() => bottomPanelRect)
38
+ // appContainerRef holds panel elements that the hook queries by id
39
+ const appContainer = document.createElement('div')
40
+ const myPanelEl = makePanelEl('myPanel')
41
+ appContainer.appendChild(myPanelEl)
34
42
 
35
43
  const layoutRefs = {
36
44
  mainRef: { current: document.createElement('div') },
37
- insetRef: { current: insetEl },
38
- bottomRef: { current: bottomEl },
45
+ appContainerRef: { current: appContainer },
39
46
  ...overrides.layoutRefs
40
47
  }
41
48
  const panelConfig = {
42
- myPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'inset' } },
49
+ myPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'left-top' } },
43
50
  emptyPanel: {},
44
51
  ...overrides.panelConfig
45
52
  }
@@ -48,10 +55,10 @@ const setup = (overrides = {}) => {
48
55
  ...overrides.panelRegistry
49
56
  }
50
57
 
51
- useConfig.mockReturnValue({ mapProvider, eventBus, ...overrides.config })
58
+ useConfig.mockReturnValue({ id: APP_ID, mapProvider, eventBus, ...overrides.config })
52
59
  useApp.mockReturnValue({ layoutRefs, panelConfig, panelRegistry, breakpoint: 'desktop', ...overrides.app })
53
60
 
54
- return { mapProvider, eventBus, capturedHandlers, layoutRefs, panelConfig, insetEl, bottomEl }
61
+ return { mapProvider, eventBus, capturedHandlers, layoutRefs, panelConfig, myPanelEl, appContainer }
55
62
  }
56
63
 
57
64
  describe('useVisibleGeometry', () => {
@@ -102,12 +109,14 @@ describe('useVisibleGeometry', () => {
102
109
  expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
103
110
  })
104
111
 
105
- test('does nothing when panel has visibleGeometry but no slot config', () => {
112
+ test('does nothing when panel element is not in the DOM', () => {
113
+ // Panel has visibleGeometry and slot config but its DOM element is not mounted yet
106
114
  const { mapProvider, capturedHandlers } = setup({
107
- panelConfig: { noSlotPanel: { visibleGeometry: polygonFeature } }
115
+ panelConfig: { noElPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'left-top' } } }
108
116
  })
109
117
  renderHook(() => useVisibleGeometry())
110
- capturedHandlers['app:panelopened']({ panelId: 'noSlotPanel' })
118
+ capturedHandlers['app:panelopened']({ panelId: 'noElPanel' })
119
+ jest.runAllTimers()
111
120
  expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
112
121
  })
113
122
 
@@ -118,19 +127,20 @@ describe('useVisibleGeometry', () => {
118
127
  expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
119
128
  })
120
129
 
121
- test('does nothing when slot ref has zero dimensions (panel not visible)', () => {
122
- const zeroEl = document.createElement('div')
123
- zeroEl.getBoundingClientRect = jest.fn(() => ({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }))
130
+ test('does nothing when panel element has zero dimensions (panel not yet visible)', () => {
131
+ const zeroRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }
132
+ const appContainer = document.createElement('div')
133
+ appContainer.appendChild(makePanelEl('myPanel', zeroRect))
124
134
  const { mapProvider, capturedHandlers } = setup({
125
135
  layoutRefs: {
126
136
  mainRef: { current: document.createElement('div') },
127
- insetRef: { current: zeroEl }
137
+ appContainerRef: { current: appContainer }
128
138
  }
129
139
  })
130
140
  renderHook(() => useVisibleGeometry())
131
141
  capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
132
142
 
133
- // Run the current pending animation frame
143
+ // Run only the first pending animation frame — panel has zero size so it reschedules
134
144
  jest.runOnlyPendingTimers()
135
145
 
136
146
  expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
@@ -151,14 +161,17 @@ describe('useVisibleGeometry', () => {
151
161
  capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
152
162
  jest.runAllTimers()
153
163
 
154
- expect(mapProvider.isGeometryObscured).toHaveBeenCalledWith(polygonFeature, insetPanelRect)
164
+ expect(mapProvider.isGeometryObscured).toHaveBeenCalledWith(polygonFeature, panelRect)
155
165
  expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
156
166
  expect(mapProvider.setView).not.toHaveBeenCalled()
157
167
  })
158
168
 
159
169
  test('calls setView with center for Point geometry when obscured', () => {
170
+ const appContainer = document.createElement('div')
171
+ appContainer.appendChild(makePanelEl('pointPanel'))
160
172
  const { mapProvider, capturedHandlers } = setup({
161
- panelConfig: { pointPanel: { visibleGeometry: pointFeature, desktop: { slot: 'inset' } } }
173
+ panelConfig: { pointPanel: { visibleGeometry: pointFeature, desktop: { slot: 'left-top' } } },
174
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } }
162
175
  })
163
176
  renderHook(() => useVisibleGeometry())
164
177
  capturedHandlers['app:panelopened']({ panelId: 'pointPanel' })
@@ -169,8 +182,11 @@ describe('useVisibleGeometry', () => {
169
182
  })
170
183
 
171
184
  test('calls setView with first coordinate for MultiPoint geometry when obscured', () => {
185
+ const appContainer = document.createElement('div')
186
+ appContainer.appendChild(makePanelEl('mpPanel'))
172
187
  const { mapProvider, capturedHandlers } = setup({
173
- panelConfig: { mpPanel: { visibleGeometry: multiPointFeature, desktop: { slot: 'inset' } } }
188
+ panelConfig: { mpPanel: { visibleGeometry: multiPointFeature, desktop: { slot: 'left-top' } } },
189
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } }
174
190
  })
175
191
  renderHook(() => useVisibleGeometry())
176
192
  capturedHandlers['app:panelopened']({ panelId: 'mpPanel' })
@@ -182,8 +198,11 @@ describe('useVisibleGeometry', () => {
182
198
 
183
199
  test('calls fitToBounds for a raw non-Feature geometry (e.g. Polygon) when obscured', () => {
184
200
  const rawPolygon = { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }
201
+ const appContainer = document.createElement('div')
202
+ appContainer.appendChild(makePanelEl('geoPanel'))
185
203
  const { mapProvider, capturedHandlers } = setup({
186
- panelConfig: { geoPanel: { visibleGeometry: rawPolygon, desktop: { slot: 'inset' } } }
204
+ panelConfig: { geoPanel: { visibleGeometry: rawPolygon, desktop: { slot: 'left-top' } } },
205
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } }
187
206
  })
188
207
  renderHook(() => useVisibleGeometry())
189
208
  capturedHandlers['app:panelopened']({ panelId: 'geoPanel' })
@@ -194,8 +213,11 @@ describe('useVisibleGeometry', () => {
194
213
 
195
214
  test('calls setView for a raw Point geometry (not Feature-wrapped) when obscured', () => {
196
215
  const rawPoint = { type: 'Point', coordinates: [1, 51] }
216
+ const appContainer = document.createElement('div')
217
+ appContainer.appendChild(makePanelEl('rawPointPanel'))
197
218
  const { mapProvider, capturedHandlers } = setup({
198
- panelConfig: { rawPointPanel: { visibleGeometry: rawPoint, desktop: { slot: 'inset' } } }
219
+ panelConfig: { rawPointPanel: { visibleGeometry: rawPoint, desktop: { slot: 'left-top' } } },
220
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } }
199
221
  })
200
222
  renderHook(() => useVisibleGeometry())
201
223
  capturedHandlers['app:panelopened']({ panelId: 'rawPointPanel' })
@@ -206,8 +228,11 @@ describe('useVisibleGeometry', () => {
206
228
 
207
229
  test('does not call setView when Point feature has null coordinates', () => {
208
230
  const nullCoordsFeature = { type: 'Feature', geometry: { type: 'Point', coordinates: null }, properties: {} }
231
+ const appContainer = document.createElement('div')
232
+ appContainer.appendChild(makePanelEl('nullPanel'))
209
233
  const { mapProvider, capturedHandlers } = setup({
210
- panelConfig: { nullPanel: { visibleGeometry: nullCoordsFeature, desktop: { slot: 'inset' } } }
234
+ panelConfig: { nullPanel: { visibleGeometry: nullCoordsFeature, desktop: { slot: 'left-top' } } },
235
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } }
211
236
  })
212
237
  renderHook(() => useVisibleGeometry())
213
238
  capturedHandlers['app:panelopened']({ panelId: 'nullPanel' })
@@ -216,26 +241,14 @@ describe('useVisibleGeometry', () => {
216
241
  expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
217
242
  })
218
243
 
219
- test('uses bottom slot ref when panel is in bottom slot', () => {
220
- const { mapProvider, capturedHandlers } = setup({
221
- panelConfig: { bottomPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'bottom' } } }
222
- })
223
- renderHook(() => useVisibleGeometry())
224
- capturedHandlers['app:panelopened']({ panelId: 'bottomPanel' })
225
- jest.runAllTimers()
226
-
227
- expect(mapProvider.isGeometryObscured).toHaveBeenCalledWith(polygonFeature, bottomPanelRect)
228
- expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
229
- })
230
-
231
244
  test('uses latest panelConfig via ref when it changes between renders', () => {
232
- const { mapProvider, capturedHandlers, insetEl } = setup()
245
+ const { mapProvider, capturedHandlers, appContainer } = setup()
233
246
  const { rerender } = renderHook(() => useVisibleGeometry())
234
247
 
235
248
  const updatedGeometry = { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} }
236
- const updatedPanelConfig = { myPanel: { visibleGeometry: updatedGeometry, desktop: { slot: 'inset' } } }
249
+ const updatedPanelConfig = { myPanel: { visibleGeometry: updatedGeometry, desktop: { slot: 'left-top' } } }
237
250
  useApp.mockReturnValue({
238
- layoutRefs: { mainRef: { current: document.createElement('div') }, insetRef: { current: insetEl } },
251
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } },
239
252
  panelConfig: updatedPanelConfig,
240
253
  panelRegistry: { getPanelConfig: jest.fn(() => updatedPanelConfig) },
241
254
  breakpoint: 'desktop'
@@ -249,20 +262,26 @@ describe('useVisibleGeometry', () => {
249
262
 
250
263
  test('uses slot from event payload when registry config lacks slot info', () => {
251
264
  const freshGeometry = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} }
265
+ const appContainer = document.createElement('div')
266
+ appContainer.appendChild(makePanelEl('freshPanel'))
252
267
  const { mapProvider, capturedHandlers } = setup({
253
- panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry } })) }
268
+ panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry } })) },
269
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } }
254
270
  })
255
271
  renderHook(() => useVisibleGeometry())
256
272
  // Event includes slot (as middleware provides for ADD_PANEL); registry config has no slot info
257
- capturedHandlers['app:panelopened']({ panelId: 'freshPanel', slot: 'inset' })
273
+ capturedHandlers['app:panelopened']({ panelId: 'freshPanel', slot: 'left-top' })
258
274
  jest.runAllTimers()
259
275
  expect(mapProvider.fitToBounds).toHaveBeenCalledWith(freshGeometry)
260
276
  })
261
277
 
262
278
  test('falls back to panelRegistry for panels not yet in stale panelConfig', () => {
263
279
  const freshGeometry = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} }
280
+ const appContainer = document.createElement('div')
281
+ appContainer.appendChild(makePanelEl('freshPanel'))
264
282
  const { mapProvider, capturedHandlers } = setup({
265
- panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry, desktop: { slot: 'inset' } } })) }
283
+ panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry, desktop: { slot: 'left-top' } } })) },
284
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } }
266
285
  })
267
286
  renderHook(() => useVisibleGeometry())
268
287
  capturedHandlers['app:panelopened']({ panelId: 'freshPanel' })
@@ -271,13 +290,16 @@ describe('useVisibleGeometry', () => {
271
290
  })
272
291
 
273
292
  test('falls back to config when panel not in panelConfig and registry returns null', () => {
293
+ const appContainer = document.createElement('div')
294
+ appContainer.appendChild(makePanelEl('missingPanel'))
274
295
  const { mapProvider, capturedHandlers } = setup({
275
296
  panelConfig: {}, // panel not present
276
- panelRegistry: { getPanelConfig: jest.fn(() => null) } // registry returns null
297
+ panelRegistry: { getPanelConfig: jest.fn(() => null) }, // registry returns null
298
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } }
277
299
  })
278
300
 
279
301
  renderHook(() => useVisibleGeometry())
280
- capturedHandlers['app:panelopened']({ panelId: 'missingPanel', visibleGeometry: polygonFeature, slot: 'inset' })
302
+ capturedHandlers['app:panelopened']({ panelId: 'missingPanel', visibleGeometry: polygonFeature, slot: 'left-top' })
281
303
  jest.runAllTimers()
282
304
  // Should still call fitToBounds using visibleGeometry from event payload
283
305
  expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
@@ -285,11 +307,14 @@ describe('useVisibleGeometry', () => {
285
307
 
286
308
  test('uses visibleGeometry from event payload directly, bypassing registry (ADD_PANEL first-click case)', () => {
287
309
  // Registry is empty — simulates first ADD_PANEL before React has processed the reducer
310
+ const appContainer = document.createElement('div')
311
+ appContainer.appendChild(makePanelEl('newPanel'))
288
312
  const { mapProvider, capturedHandlers } = setup({
289
- panelRegistry: { getPanelConfig: jest.fn(() => ({})) }
313
+ panelRegistry: { getPanelConfig: jest.fn(() => ({})) },
314
+ layoutRefs: { mainRef: { current: document.createElement('div') }, appContainerRef: { current: appContainer } }
290
315
  })
291
316
  renderHook(() => useVisibleGeometry())
292
- capturedHandlers['app:panelopened']({ panelId: 'newPanel', slot: 'inset', visibleGeometry: polygonFeature })
317
+ capturedHandlers['app:panelopened']({ panelId: 'newPanel', slot: 'left-top', visibleGeometry: polygonFeature })
293
318
  jest.runAllTimers()
294
319
  expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
295
320
  })
@@ -63,20 +63,25 @@ export const Layout = () => {
63
63
  <SlotRenderer slot={layoutSlots.TOP_RIGHT} />
64
64
  </div>
65
65
  </div>
66
- <div className='im-o-app__inset' ref={layoutRefs.insetRef}>
67
- <SlotRenderer slot={layoutSlots.INSET} />
66
+ <div className='im-o-app__left' ref={layoutRefs.leftRef}>
67
+ <div className='im-o-app__left-top' ref={layoutRefs.leftTopRef}>
68
+ <SlotRenderer slot={layoutSlots.LEFT_TOP} />
69
+ </div>
70
+ <div className='im-o-app__left-bottom' ref={layoutRefs.leftBottomRef}>
71
+ <SlotRenderer slot={layoutSlots.LEFT_BOTTOM} />
72
+ </div>
73
+ </div>
74
+ <div className='im-o-app__middle' ref={layoutRefs.middleRef}>
75
+ <SlotRenderer slot={layoutSlots.MIDDLE} />
68
76
  </div>
69
77
  <div className='im-o-app__right' ref={layoutRefs.rightRef}>
70
- <div className='im-o-app__right-top'>
78
+ <div className='im-o-app__right-top' ref={layoutRefs.rightTopRef}>
71
79
  <SlotRenderer slot={layoutSlots.RIGHT_TOP} />
72
80
  </div>
73
81
  <div className='im-o-app__right-bottom' ref={layoutRefs.rightBottomRef}>
74
82
  <SlotRenderer slot={layoutSlots.RIGHT_BOTTOM} />
75
83
  </div>
76
84
  </div>
77
- <div className='im-o-app__middle' ref={layoutRefs.middleRef}>
78
- <SlotRenderer slot={layoutSlots.MIDDLE} />
79
- </div>
80
85
  <div className='im-o-app__footer' ref={layoutRefs.footerRef}>
81
86
  <div className='im-o-app__footer-col'>
82
87
  <Logo />
@@ -37,7 +37,6 @@ describe('Layout', () => {
37
37
  topRef: React.createRef(),
38
38
  topLeftColRef: React.createRef(),
39
39
  topRightColRef: React.createRef(),
40
- insetRef: React.createRef(),
41
40
  rightRef: React.createRef(),
42
41
  footerRef: React.createRef(),
43
42
  actionsRef: React.createRef()
@@ -139,12 +139,49 @@
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
+ gap: var(--divider-gap);
157
+
158
+ & > *:empty {
159
+ display: none;
160
+ }
161
+
162
+ @media (prefers-reduced-motion: no-preference) {
163
+ transition: bottom 0.15s ease;
164
+ }
165
+ }
166
+
167
+ .im-o-app__left-top {
168
+ display: flex;
169
+ flex-direction: column;
170
+ align-items: flex-start;
171
+ min-height: 0;
172
+ position: relative;
173
+ gap: var(--divider-gap);
174
+ }
175
+
176
+ .im-o-app__left-bottom {
177
+ display: flex;
178
+ flex-direction: column;
179
+ align-items: flex-start;
180
+ justify-content: flex-end;
181
+ min-height: 0;
182
+ margin-top: auto;
183
+ position: relative;
184
+ gap: var(--divider-gap);
148
185
  }
149
186
 
150
187
  // ---------------------------------------------------
@@ -164,7 +201,7 @@
164
201
  }
165
202
 
166
203
  // ---------------------------------------------------
167
- // Right: Buttons and inset panel
204
+ // Right: Buttons and panels
168
205
  // ---------------------------------------------------
169
206
 
170
207
  .im-o-app__right {
@@ -174,18 +211,30 @@
174
211
  right: var(--primary-gap);
175
212
  top: var(--right-offset-top);
176
213
  bottom: var(--right-offset-bottom);
214
+ gap: var(--divider-gap);
215
+
216
+ @media (prefers-reduced-motion: no-preference) {
217
+ transition: bottom 0.15s ease;
218
+ }
177
219
  }
178
220
 
179
221
  .im-o-app__right-top {
180
222
  display: flex;
181
223
  flex-direction: column;
182
- flex-shrink: 0;
224
+ flex: 0 0 auto;
183
225
  align-items: flex-end;
226
+ position: relative;
184
227
  gap: var(--divider-gap);
185
228
  }
186
229
 
187
230
  .im-o-app__right-bottom {
231
+ display: flex;
232
+ flex-direction: column;
233
+ flex: 0 0 auto;
188
234
  margin-top: auto;
235
+ align-items: flex-end;
236
+ position: relative;
237
+ gap: var(--divider-gap);
189
238
  }
190
239
 
191
240
  // ---------------------------------------------------
@@ -301,10 +350,33 @@
301
350
  }
302
351
 
303
352
  .im-c-panel--inset {
304
- top: var(--inset-offset-top);
305
- left: var(--primary-gap);
353
+ inset: var(--modal-inset);
306
354
  max-width: calc(100% - (var(--primary-gap) * 2));
307
- max-height: calc(100% - var(--inset-offset-top) - var(--primary-gap));
355
+ max-height: var(--modal-max-height);
356
+ }
357
+
358
+ .im-c-panel--left-top,
359
+ .im-c-panel.im-c-panel--left-top-button {
360
+ inset: var(--left-offset-top) auto auto var(--primary-gap);
361
+ max-height: calc(100% - var(--left-offset-top) - var(--primary-gap));
362
+ }
363
+
364
+ .im-c-panel--left-bottom,
365
+ .im-c-panel.im-c-panel--left-bottom-button {
366
+ inset: auto auto var(--left-offset-bottom) var(--primary-gap);
367
+ max-height: calc(100% - var(--left-offset-bottom) - var(--primary-gap));
368
+ }
369
+
370
+ .im-c-panel--right-top,
371
+ .im-c-panel.im-c-panel--right-top-button {
372
+ inset: var(--right-offset-top) var(--primary-gap) auto auto;
373
+ max-height: calc(100% - var(--right-offset-top) - var(--primary-gap));
374
+ }
375
+
376
+ .im-c-panel--right-bottom,
377
+ .im-c-panel.im-c-panel--right-bottom-button {
378
+ inset: auto var(--primary-gap) var(--right-offset-bottom) auto;
379
+ max-height: calc(100% - var(--right-offset-bottom) - var(--primary-gap));
308
380
  }
309
381
 
310
382
  .im-c-panel--middle {
@@ -323,7 +395,9 @@
323
395
 
324
396
  [class*="im-c-panel--"][class*="-button"] { // Adjacent to button
325
397
  inset: var(--modal-inset);
398
+ max-height: var(--modal-max-height);
326
399
  }
400
+
327
401
  }
328
402
 
329
403
  // Mobile and tablet
@@ -378,7 +452,6 @@
378
452
  width: 100%;
379
453
  left: 0;
380
454
  bottom: calc(var(--primary-gap) * 2);
381
- padding-left: var(--offset-left);
382
455
 
383
456
  .im-c-panel {
384
457
  max-width: var(--action-bar-max-width);
@@ -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.
@@ -15,13 +14,20 @@ export const getSlotRef = (slot, layoutRefs) => {
15
14
  banner: layoutRefs.bannerRef,
16
15
  'top-left': layoutRefs.topLeftColRef,
17
16
  'top-right': layoutRefs.topRightColRef,
18
- inset: layoutRefs.insetRef,
17
+ 'left-top': layoutRefs.leftTopRef,
18
+ 'left-bottom': layoutRefs.leftBottomRef,
19
19
  middle: layoutRefs.middleRef,
20
- bottom: layoutRefs.bottomRef,
20
+ 'right-top': layoutRefs.rightTopRef,
21
21
  'right-bottom': layoutRefs.rightBottomRef,
22
+ bottom: layoutRefs.bottomRef,
22
23
  actions: layoutRefs.actionsRef,
23
24
  modal: layoutRefs.modalRef
24
25
  }
26
+ if (slot?.endsWith('-button')) {
27
+ const el = document.querySelector(`[data-button-slot="${slot}"]`)
28
+ return el ? { current: el } : null
29
+ }
30
+
25
31
  return slotRefMap[slot] || null
26
32
  }
27
33
 
@@ -79,7 +85,7 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, allowedModal
79
85
  }
80
86
 
81
87
  // 2. Slot Validation
82
- const isNextToButton = `${stringToKebab(panelId)}-button` === targetSlot
88
+ const isNextToButton = targetSlot.endsWith('-button')
83
89
  const isSlotAllowed = allowedSlots.panel.includes(targetSlot) || isNextToButton
84
90
 
85
91
  if (!isSlotAllowed) {
@@ -14,8 +14,8 @@ jest.mock('../components/Panel/Panel.jsx', () => ({
14
14
  }))
15
15
  jest.mock('./slots.js', () => ({
16
16
  allowedSlots: {
17
- panel: ['inset', 'side', 'modal', 'bottom'],
18
- control: ['inset', 'banner', 'bottom', 'actions']
17
+ panel: ['left-top', 'side', 'modal', 'bottom'],
18
+ control: ['left-top', 'banner', 'bottom', 'actions']
19
19
  }
20
20
  }))
21
21
 
@@ -25,7 +25,7 @@ jest.mock('./slots.js', () => ({
25
25
  */
26
26
  const SlotHarness = ({ layoutRefs, children }) => (
27
27
  <div>
28
- <div ref={layoutRefs.insetRef} data-slot='inset' />
28
+ <div ref={layoutRefs.leftTopRef} data-slot='left-top' />
29
29
  <div ref={layoutRefs.sideRef} data-slot='side' />
30
30
  <div ref={layoutRefs.modalRef} data-slot='modal' />
31
31
  <div ref={layoutRefs.bottomRef} data-slot='bottom' />
@@ -45,7 +45,7 @@ describe('HtmlElementHost', () => {
45
45
  bannerRef: { current: null },
46
46
  topLeftColRef: { current: null },
47
47
  topRightColRef: { current: null },
48
- insetRef: { current: null },
48
+ leftTopRef: { current: null },
49
49
  middleRef: { current: null },
50
50
  bottomRef: { current: null },
51
51
  actionsRef: { current: null },
@@ -105,10 +105,10 @@ describe('HtmlElementHost', () => {
105
105
 
106
106
  it('projects open panel into correct slot', () => {
107
107
  const { container } = renderWithSlots({
108
- panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'inset' } } },
108
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'left-top' } } },
109
109
  openPanels: { p1: { props: {} } }
110
110
  })
111
- expect(container.querySelector('[data-slot="inset"] [data-testid="panel-p1"]')).toBeTruthy()
111
+ expect(container.querySelector('[data-slot="left-top"] [data-testid="panel-p1"]')).toBeTruthy()
112
112
  })
113
113
 
114
114
  it('hides panel when closed and passes isOpen=false', () => {
@@ -130,19 +130,28 @@ describe('HtmlElementHost', () => {
130
130
 
131
131
  it('hides panel with inline:false when not fullscreen', () => {
132
132
  const { getByTestId } = renderWithSlots({
133
- panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'inset' }, inline: false } },
133
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'left-top' }, inline: false } },
134
134
  openPanels: { p1: { props: {} } },
135
135
  isFullscreen: false
136
136
  })
137
137
  expect(getByTestId('panel-p1').style.display).toBe('none')
138
138
  })
139
139
 
140
- it('resolves bottom slot to inset on desktop', () => {
140
+ it('shows panel with inline:false when fullscreen', () => {
141
+ const { getByTestId } = renderWithSlots({
142
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'left-top' }, inline: false } },
143
+ openPanels: { p1: { props: {} } },
144
+ isFullscreen: true
145
+ })
146
+ expect(getByTestId('panel-p1').dataset.open).toBe('true')
147
+ })
148
+
149
+ it('resolves bottom slot to left-top on desktop', () => {
141
150
  const { container } = renderWithSlots({
142
151
  panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'bottom' } } },
143
152
  openPanels: { p1: { props: {} } }
144
153
  })
145
- expect(container.querySelector('[data-slot="inset"] [data-testid="panel-p1"]')).toBeTruthy()
154
+ expect(container.querySelector('[data-slot="left-top"] [data-testid="panel-p1"]')).toBeTruthy()
146
155
  expect(container.querySelector('[data-slot="bottom"] [data-testid="panel-p1"]')).toBeNull()
147
156
  })
148
157
 
@@ -169,9 +178,9 @@ describe('HtmlElementHost', () => {
169
178
 
170
179
  it('projects visible control into correct slot', () => {
171
180
  const { container } = renderWithSlots({
172
- controlConfig: { c1: { id: 'c1', html: '<input type="checkbox">', desktop: { slot: 'inset' } } }
181
+ controlConfig: { c1: { id: 'c1', html: '<input type="checkbox">', desktop: { slot: 'left-top' } } }
173
182
  })
174
- const control = container.querySelector('[data-slot="inset"] .im-c-control')
183
+ const control = container.querySelector('[data-slot="left-top"] .im-c-control')
175
184
  expect(control).toBeTruthy()
176
185
  expect(control.innerHTML).toBe('<input type="checkbox">')
177
186
  })
@@ -292,6 +301,18 @@ describe('HtmlElementHost', () => {
292
301
  expect(getSlotRef('unknown-slot', {})).toBeNull()
293
302
  })
294
303
 
304
+ test('getSlotRef returns wrapped element for button slot when element exists', () => {
305
+ const el = document.createElement('div')
306
+ el.dataset.buttonSlot = 'my-panel-button'
307
+ document.body.appendChild(el)
308
+ expect(getSlotRef('my-panel-button', {})).toEqual({ current: el })
309
+ el.remove()
310
+ })
311
+
312
+ test('getSlotRef returns null for button slot when no element found', () => {
313
+ expect(getSlotRef('nonexistent-button', {})).toBeNull()
314
+ })
315
+
295
316
  it('does not append child if slotRef exists but current is null', () => {
296
317
  // 1. Setup refs where the slot exists in the map but the DOM node (current) is null
297
318
  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
  )