@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
@@ -4,7 +4,7 @@ import { attachAppEvents } from './appEvents.js'
4
4
  import { createMapLabelNavigator } from './utils/labels.js'
5
5
  import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
6
6
  import { queryFeatures } from './utils/queryFeatures.js'
7
- import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds } from './utils/spatial.js'
7
+ import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds, isGeometryObscured } from './utils/spatial.js'
8
8
 
9
9
  jest.mock('./defaults.js', () => ({
10
10
  DEFAULTS: { animationDuration: 400, coordinatePrecision: 7 },
@@ -20,6 +20,7 @@ jest.mock('./utils/spatial.js', () => ({
20
20
  getAreaDimensions: jest.fn(() => '400m by 750m'),
21
21
  getCardinalMove: jest.fn(() => 'north'),
22
22
  getBboxFromGeoJSON: jest.fn(() => [-1, 50, 1, 52]),
23
+ isGeometryObscured: jest.fn(() => true),
23
24
  getResolution: jest.fn(() => 10),
24
25
  getPaddedBounds: jest.fn(() => [[0, 0], [1, 1]])
25
26
  }))
@@ -170,6 +171,18 @@ describe('MapLibreProvider', () => {
170
171
  expect(map.fitBounds).toHaveBeenCalledWith([-1, 50, 1, 52], { duration: 400 })
171
172
  })
172
173
 
174
+ test('isGeometryObscured delegates to spatial utility with map instance', async () => {
175
+ const p = makeProvider()
176
+ await doInitMap(p)
177
+ const geojson = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 52] }, properties: {} }
178
+ const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
179
+
180
+ const result = p.isGeometryObscured(geojson, panelRect)
181
+
182
+ expect(isGeometryObscured).toHaveBeenCalledWith(geojson, panelRect, map)
183
+ expect(result).toBe(true)
184
+ })
185
+
173
186
  test('getCenter, getZoom, getBounds return formatted values', async () => {
174
187
  const p = makeProvider()
175
188
  await doInitMap(p)
@@ -196,10 +196,50 @@ const getPaddedBounds = (LngLatBounds, map) => {
196
196
  */
197
197
  const getBboxFromGeoJSON = (geojson) => turfBbox(geojson)
198
198
 
199
+ /**
200
+ * Returns true if the geometry's screen bounding box overlaps the given panel rectangle.
201
+ * Used to decide whether to pan/zoom when a panel opens over a visibleGeometry target.
202
+ *
203
+ * @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry
204
+ * @param {DOMRect} panelRect - Bounding rect of the panel element (viewport coordinates)
205
+ * @param {object} map - MapLibre map instance
206
+ * @returns {boolean}
207
+ */
208
+ const isGeometryObscured = (geojson, panelRect, map) => {
209
+ const containerRect = map.getContainer().getBoundingClientRect()
210
+ const [west, south, east, north] = getBboxFromGeoJSON(geojson)
211
+
212
+ const corners = [
213
+ map.project([west, south]),
214
+ map.project([west, north]),
215
+ map.project([east, south]),
216
+ map.project([east, north])
217
+ ]
218
+
219
+ const screenMinX = Math.min(...corners.map(c => c.x))
220
+ const screenMaxX = Math.max(...corners.map(c => c.x))
221
+ const screenMinY = Math.min(...corners.map(c => c.y))
222
+ const screenMaxY = Math.max(...corners.map(c => c.y))
223
+
224
+ // Convert panelRect from viewport coords to map-container-relative coords
225
+ const panelLeft = panelRect.left - containerRect.left
226
+ const panelTop = panelRect.top - containerRect.top
227
+ const panelRight = panelRect.right - containerRect.left
228
+ const panelBottom = panelRect.bottom - containerRect.top
229
+
230
+ return (
231
+ screenMinX < panelRight &&
232
+ screenMaxX > panelLeft &&
233
+ screenMinY < panelBottom &&
234
+ screenMaxY > panelTop
235
+ )
236
+ }
237
+
199
238
  export {
200
239
  getAreaDimensions,
201
240
  getCardinalMove,
202
241
  getBboxFromGeoJSON,
242
+ isGeometryObscured,
203
243
  spatialNavigate,
204
244
  getResolution,
205
245
  getPaddedBounds,
@@ -105,4 +105,39 @@ describe('spatial utils', () => {
105
105
  expect(turfBbox).toHaveBeenCalledWith(feature)
106
106
  expect(result).toEqual([-1, 50, 1, 52])
107
107
  })
108
+
109
+ describe('isGeometryObscured', () => {
110
+ const geojson = { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 51] }, properties: {} }
111
+ // getBboxFromGeoJSON is mocked to always return [-1, 50, 1, 52]
112
+
113
+ // Container sits at viewport origin so container-relative coords equal viewport coords
114
+ const makeMap = (projectFn) => ({
115
+ getContainer: jest.fn(() => ({
116
+ getBoundingClientRect: jest.fn(() => ({ left: 0, top: 0, right: 1000, bottom: 800 }))
117
+ })),
118
+ project: jest.fn(projectFn)
119
+ })
120
+
121
+ // Panel occupies the right 400px of the viewport
122
+ const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
123
+
124
+ test('returns true when geometry screen bbox overlaps the panel rect', () => {
125
+ // Corners project into the panel (x: 650 is between panelLeft 600 and panelRight 1000)
126
+ const map = makeMap(() => ({ x: 650, y: 400 }))
127
+ expect(spatial.isGeometryObscured(geojson, panelRect, map)).toBe(true)
128
+ })
129
+
130
+ test('returns false when geometry screen bbox does not overlap the panel rect', () => {
131
+ // Corners project to x: 300, entirely left of panelLeft (600)
132
+ const map = makeMap(() => ({ x: 300, y: 400 }))
133
+ expect(spatial.isGeometryObscured(geojson, panelRect, map)).toBe(false)
134
+ })
135
+
136
+ test('projects all four bbox corners', () => {
137
+ const map = makeMap(() => ({ x: 300, y: 400 }))
138
+ spatial.isGeometryObscured(geojson, panelRect, map)
139
+ // bbox is [-1, 50, 1, 52]: corners are [-1,50], [-1,52], [1,50], [1,52]
140
+ expect(map.project).toHaveBeenCalledTimes(4)
141
+ })
142
+ })
108
143
  })
@@ -244,6 +244,7 @@ export const MapButton = ({
244
244
  return (
245
245
  <div
246
246
  className={buildWrapperClassNames(buttonId, showLabel)}
247
+ data-button-slot={panelId ? `${stringToKebab(buttonId)}-button` : undefined}
247
248
  style={isHidden ? { display: 'none' } : undefined}
248
249
  >
249
250
  {showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
@@ -10,17 +10,17 @@ const computePanelState = (bpConfig, triggeringElement) => {
10
10
  const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
11
11
  const isDialog = !isAside && bpConfig.dismissible
12
12
  const isModal = bpConfig.modal === true
13
- const isDismissable = bpConfig.dismissible !== false
13
+ const isDismissible = bpConfig.dismissible !== false
14
14
  const shouldFocus = Boolean(isModal || triggeringElement)
15
15
  const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
16
- return { isAside, isDialog, isModal, isDismissable, shouldFocus, buttonContainerEl }
16
+ return { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl }
17
17
  }
18
18
 
19
- const getPanelRole = (isDialog, isDismissable) => {
19
+ const getPanelRole = (isDialog, isDismissible) => {
20
20
  if (isDialog) {
21
21
  return 'dialog'
22
22
  }
23
- if (isDismissable) {
23
+ if (isDismissible) {
24
24
  return 'complementary'
25
25
  }
26
26
  return 'region'
@@ -32,19 +32,20 @@ const buildPanelClassNames = (slot, showLabel) => [
32
32
  !showLabel && 'im-c-panel--no-heading'
33
33
  ].filter(Boolean).join(' ')
34
34
 
35
- const buildPanelBodyClassNames = (showLabel, isDismissable) => [
35
+ const buildPanelBodyClassNames = (showLabel, isDismissible) => [
36
36
  'im-c-panel__body',
37
- !showLabel && isDismissable && 'im-c-panel__body--offset'
37
+ !showLabel && isDismissible && 'im-c-panel__body--offset'
38
38
  ].filter(Boolean).join(' ')
39
39
 
40
- const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissable, isModal, width, panelClass }) => ({
40
+ const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isModal, width, panelClass, slot }) => ({
41
41
  id: elementId,
42
42
  'aria-labelledby': `${elementId}-label`,
43
43
  tabIndex: shouldFocus ? -1 : undefined, // nosonar
44
- role: getPanelRole(isDialog, isDismissable),
44
+ role: getPanelRole(isDialog, isDismissible),
45
45
  'aria-modal': isDialog && isModal ? 'true' : undefined,
46
46
  style: width ? { width } : undefined,
47
- className: panelClass
47
+ className: panelClass,
48
+ 'data-slot': slot
48
49
  })
49
50
 
50
51
  const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }) => ({
@@ -65,7 +66,7 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
65
66
  const bpConfig = panelConfig[breakpoint]
66
67
  const elementId = `${id}-panel-${stringToKebab(panelId)}`
67
68
 
68
- const { isAside, isDialog, isModal, isDismissable, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement)
69
+ const { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement)
69
70
 
70
71
  // For persistent panels, gate modal behaviour on open state
71
72
  const isModalActive = isModal && isOpen
@@ -97,10 +98,10 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
97
98
  }, [isOpen])
98
99
 
99
100
  const panelClass = buildPanelClassNames(bpConfig.slot, bpConfig.showLabel ?? true)
100
- const panelBodyClass = buildPanelBodyClassNames(bpConfig.showLabel ?? true, isDismissable)
101
+ const panelBodyClass = buildPanelBodyClassNames(bpConfig.showLabel ?? true, isDismissible)
101
102
  const innerHtmlProp = useMemo(() => html ? { __html: html } : null, [html])
102
103
 
103
- const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissable, isModal, width: bpConfig.width, panelClass })
104
+ const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot })
104
105
  const bodyProps = buildBodyProps({ bodyRef, panelBodyClass, isBodyScrollable, elementId })
105
106
 
106
107
  return (
@@ -115,7 +116,7 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
115
116
  {label}
116
117
  </h2>
117
118
 
118
- {isDismissable && (
119
+ {isDismissible && (
119
120
  <button
120
121
  aria-label={`Close ${label}`}
121
122
  className='im-c-panel__close'
@@ -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'
@@ -58,6 +59,9 @@ export const MapController = ({ mapContainerRef }) => {
58
59
  // Override mapProvider functions
59
60
  useMapProviderOverrides()
60
61
 
62
+ // Pan/zoom to keep visibleGeometry visible when panels open
63
+ useVisibleGeometry()
64
+
61
65
  // Update padding when breakpoint or mapSize change
62
66
  useEffect(() => {
63
67
  if (!isMapReady || !syncMapPadding) {
@@ -4,6 +4,17 @@ import { useApp } from '../store/appContext.js'
4
4
  import { useMap } from '../store/mapContext.js'
5
5
  import { getSafeZoneInset } from '../../utils/getSafeZoneInset.js'
6
6
 
7
+ const buttonHeight = (ref) => ref?.current?.offsetHeight ?? 0
8
+
9
+ const topColWidth = (left, right) =>
10
+ left || right ? Math.max(left, right) : 0
11
+
12
+ const subSlotMaxHeight = (columnHeight, siblingButtons, gap) =>
13
+ columnHeight - (siblingButtons ? siblingButtons + gap : 0)
14
+
15
+ const calcOffsetLeft = (bottomOffsetTop, gap, insetBottom, inset) =>
16
+ bottomOffsetTop - gap > insetBottom ? 0 : inset.offsetLeft + inset.offsetWidth
17
+
7
18
  export function useLayoutMeasurements () {
8
19
  const { dispatch, breakpoint, layoutRefs } = useApp()
9
20
  const { mapSize, isMapReady } = useMap()
@@ -17,7 +28,11 @@ export function useLayoutMeasurements () {
17
28
  topRightColRef,
18
29
  insetRef,
19
30
  footerRef,
20
- actionsRef
31
+ actionsRef,
32
+ leftTopRef,
33
+ leftBottomRef,
34
+ rightTopRef,
35
+ rightBottomRef
21
36
  } = layoutRefs
22
37
 
23
38
  // -----------------------------
@@ -33,36 +48,38 @@ export function useLayoutMeasurements () {
33
48
  const bottom = footerRef.current
34
49
  const actions = actionsRef.current
35
50
 
36
- if (!main || !top || !inset || !bottom) {
51
+ if ([main, top, inset, bottom].some(r => !r)) {
37
52
  return
38
53
  }
39
54
 
40
55
  const root = document.documentElement
41
56
  const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
42
57
 
43
- // === Inset offsets ===
44
- const insetOffsetTop = topLeftCol.offsetHeight + top.offsetTop
45
- const insetMaxHeight = main.offsetHeight - insetOffsetTop - top.offsetTop
46
- appContainer.style.setProperty('--inset-offset-top', `${insetOffsetTop}px`)
47
- appContainer.style.setProperty('--inset-max-height', `${insetMaxHeight}px`)
58
+ // === Top column width ===
59
+ appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
48
60
 
49
- // === Bottom left offset ===
50
- const insetBottom = inset.offsetHeight + insetOffsetTop
51
- const bottomOffsetTop = Math.min(bottom.offsetTop, actions.offsetTop)
52
- const bottomOffsetLeft = bottomOffsetTop - dividerGap > insetBottom ? 0 : inset.offsetLeft + inset.offsetWidth
53
- appContainer.style.setProperty('--offset-left', `${bottomOffsetLeft}px`)
61
+ // === Left container offsets ===
62
+ const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
63
+ const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
64
+ appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
65
+ appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
66
+ appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
54
67
 
55
68
  // === Right container offsets ===
56
69
  const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
57
- const rightOffsetBottom = main.offsetHeight - bottom.offsetTop + dividerGap
70
+ const rightColumnHeight = bottom.offsetTop - rightOffsetTop - dividerGap
58
71
  appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
59
- appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
72
+ appContainer.style.setProperty('--right-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
73
+ appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
60
74
 
61
- // === Top column width ===
62
- const leftWidth = topLeftCol.offsetWidth || 0
63
- const rightWidth = topRightCol.offsetWidth || 0
64
- const finalWidth = leftWidth || rightWidth ? Math.max(leftWidth, rightWidth) : 0
65
- appContainer.style.setProperty('--top-col-width', `${finalWidth}px`)
75
+ // === Sub-slot panel max-heights ===
76
+ appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
77
+ appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
78
+ appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
79
+ appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
80
+
81
+ // === Bottom left offset ===
82
+ appContainer.style.setProperty('--offset-left', `${calcOffsetLeft(Math.min(bottom.offsetTop, actions.offsetTop), dividerGap, inset.offsetHeight + leftOffsetTop, inset)}px`)
66
83
  }
67
84
 
68
85
  // --------------------------------
@@ -81,7 +98,7 @@ export function useLayoutMeasurements () {
81
98
  // --------------------------------
82
99
  // 3. Recaluclate CSS vars when elements resize
83
100
  // --------------------------------
84
- useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, footerRef], () => {
101
+ useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, footerRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef], () => {
85
102
  requestAnimationFrame(() => {
86
103
  calculateLayout()
87
104
  })
@@ -26,7 +26,11 @@ const refs = (o = {}) => ({
26
26
  topRightColRef: { current: el({ offsetHeight: 40, offsetWidth: 180, ...o.topRightCol }) },
27
27
  insetRef: { current: o.inset === null ? null : el({ offsetHeight: 100, offsetLeft: 20, offsetWidth: 300, ...o.inset }) },
28
28
  footerRef: { current: o.footer === null ? null : el({ offsetTop: 400, ...o.footer }) },
29
- actionsRef: { current: el({ offsetTop: 450, ...o.actions }) }
29
+ actionsRef: { current: el({ offsetTop: 450, ...o.actions }) },
30
+ leftTopRef: { current: el({ offsetHeight: 0, ...o.leftTop }) },
31
+ leftBottomRef: { current: el({ offsetHeight: 0, ...o.leftBottom }) },
32
+ rightTopRef: { current: el({ offsetHeight: 0, ...o.rightTop }) },
33
+ rightBottomRef: { current: el({ offsetHeight: 0, ...o.rightBottom }) }
30
34
  })
31
35
 
32
36
  const setup = (o = {}) => {
@@ -62,17 +66,18 @@ describe('useLayoutMeasurements', () => {
62
66
  const { layoutRefs } = setup()
63
67
  renderHook(() => useLayoutMeasurements())
64
68
  const spy = layoutRefs.appContainerRef.current.style.setProperty
65
- ;['--inset-offset-top', '--inset-max-height', '--offset-left', '--right-offset-top', '--right-offset-bottom', '--top-col-width']
69
+ ;['--offset-left', '--right-offset-top', '--right-offset-bottom', '--top-col-width']
66
70
  .forEach(prop => expect(spy).toHaveBeenCalledWith(prop, expect.any(String)))
67
71
  })
68
72
 
69
73
  test.each([
70
- ['inset-offset-top', { main: { offsetHeight: 500 }, top: { offsetTop: 20 }, topLeftCol: { offsetHeight: 50 } }, '70px'],
71
- ['inset-max-height', { main: { offsetHeight: 500 }, top: { offsetTop: 20 }, topLeftCol: { offsetHeight: 50 } }, '410px'],
72
74
  ['offset-left with overlap', { inset: { offsetHeight: 200, offsetLeft: 30, offsetWidth: 150 }, footer: { offsetTop: 100 }, actions: { offsetTop: 120 }, topLeftCol: { offsetHeight: 50 }, top: { offsetTop: 10 } }, '180px'],
73
75
  ['offset-left without overlap', { inset: { offsetHeight: 50, offsetLeft: 30, offsetWidth: 150 }, footer: { offsetTop: 200 }, actions: { offsetTop: 220 }, topLeftCol: { offsetHeight: 50 }, top: { offsetTop: 10 } }, '0px'],
74
76
  ['right-offset-top', { topRightCol: { offsetHeight: 80 }, top: { offsetTop: 15 } }, '95px'],
75
- ['right-offset-bottom', { main: { offsetHeight: 600 }, footer: { offsetTop: 500 } }, '108px']
77
+ ['right-offset-bottom', { main: { offsetHeight: 600 }, footer: { offsetTop: 500 } }, '108px'],
78
+ // leftColumnHeight = 400 - (50+10) - 8 = 332; rightColumnHeight = 400 - (40+10) - 8 = 342
79
+ ['left-top-max-height', {}, '332px'],
80
+ ['right-top-max-height', {}, '342px']
76
81
  ])('calculates %s correctly', (name, refOverrides, expected) => {
77
82
  const { layoutRefs } = setup({ refs: refOverrides })
78
83
  renderHook(() => useLayoutMeasurements())
@@ -80,6 +85,21 @@ describe('useLayoutMeasurements', () => {
80
85
  expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith(varName, expected)
81
86
  })
82
87
 
88
+ test.each([
89
+ ['--left-top-panel-max-height', {}, '332px'],
90
+ ['--left-top-panel-max-height', { leftBottom: { offsetHeight: 50 } }, '274px'], // 332 - 50 - 8
91
+ ['--left-bottom-panel-max-height', {}, '332px'],
92
+ ['--left-bottom-panel-max-height', { leftTop: { offsetHeight: 40 } }, '284px'], // 332 - 40 - 8
93
+ ['--right-top-panel-max-height', {}, '342px'],
94
+ ['--right-top-panel-max-height', { rightBottom: { offsetHeight: 60 } }, '274px'], // 342 - 60 - 8
95
+ ['--right-bottom-panel-max-height', {}, '342px'],
96
+ ['--right-bottom-panel-max-height', { rightTop: { offsetHeight: 30 } }, '304px'] // 342 - 30 - 8
97
+ ])('calculates %s with sibling buttons=%o correctly', (varName, refOverrides, expected) => {
98
+ const { layoutRefs } = setup({ refs: refOverrides })
99
+ renderHook(() => useLayoutMeasurements())
100
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith(varName, expected)
101
+ })
102
+
83
103
  test.each([
84
104
  [{ offsetWidth: 250 }, { offsetWidth: 200 }, '250px'],
85
105
  [{ offsetWidth: 0 }, { offsetWidth: 200 }, '200px'],
@@ -90,6 +110,18 @@ describe('useLayoutMeasurements', () => {
90
110
  expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--top-col-width', expected)
91
111
  })
92
112
 
113
+ test('uses 0 when sub-slot refs have null current', () => {
114
+ const { layoutRefs } = setup()
115
+ layoutRefs.leftTopRef.current = null
116
+ layoutRefs.leftBottomRef.current = null
117
+ layoutRefs.rightTopRef.current = null
118
+ layoutRefs.rightBottomRef.current = null
119
+ renderHook(() => useLayoutMeasurements())
120
+ // With all sub-slot refs null, buttons = 0 ?? 0 = 0, so max-heights equal full column height
121
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--left-top-panel-max-height', '332px')
122
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--right-bottom-panel-max-height', '342px')
123
+ })
124
+
93
125
  test('dispatches safe zone inset', () => {
94
126
  const { dispatch, layoutRefs } = setup()
95
127
  getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 15, left: 5 })
@@ -114,7 +146,7 @@ describe('useLayoutMeasurements', () => {
114
146
  const { layoutRefs } = setup()
115
147
  renderHook(() => useLayoutMeasurements())
116
148
  expect(useResizeObserver).toHaveBeenCalledWith(
117
- [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.footerRef],
149
+ [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.footerRef, layoutRefs.leftTopRef, layoutRefs.leftBottomRef, layoutRefs.rightTopRef, layoutRefs.rightBottomRef],
118
150
  expect.any(Function)
119
151
  )
120
152
  layoutRefs.appContainerRef.current.style.setProperty.mockClear()
@@ -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
  }
@@ -2,6 +2,65 @@ import { useEffect } from 'react'
2
2
  import { useResizeObserver } from './useResizeObserver.js'
3
3
  import { constrainKeyboardFocus } from '../../utils/constrainKeyboardFocus.js'
4
4
  import { toggleInertElements } from '../../utils/toggleInertElements.js'
5
+ import { useApp } from '../store/appContext.js'
6
+
7
+ // Left/right slots reuse the layout CSS vars set by useLayoutMeasurements — no DOM measurement needed.
8
+ // CSS var references resolve correctly at the panel element (inside .im-o-app) even though
9
+ // --modal-inset is set on :root.
10
+ const SLOT_MODAL_VARS = {
11
+ 'left-top': { inset: 'var(--left-offset-top) auto auto var(--primary-gap)', maxHeight: 'var(--left-top-max-height)' },
12
+ 'left-bottom': { inset: 'auto auto var(--left-offset-bottom) var(--primary-gap)', maxHeight: 'var(--left-top-max-height)' },
13
+ 'right-top': { inset: 'var(--right-offset-top) var(--primary-gap) auto auto', maxHeight: 'var(--right-top-max-height)' },
14
+ 'right-bottom': { inset: 'auto var(--primary-gap) var(--right-offset-bottom) auto', maxHeight: 'var(--right-top-max-height)' }
15
+ }
16
+
17
+ const MODAL_INSET = '--modal-inset'
18
+ const MODAL_MAX_HEIGHT = '--modal-max-height'
19
+
20
+ const setButtonCSSVar = (effectiveContainer, mainRef, dividerGap) => {
21
+ const root = document.documentElement
22
+ const mainRect = mainRef.current.getBoundingClientRect()
23
+ const buttonRect = effectiveContainer.getBoundingClientRect()
24
+ const isBottomSlot = !!effectiveContainer.closest('.im-o-app__left-bottom, .im-o-app__right-bottom')
25
+ const isLeftSlot = !!effectiveContainer.closest('.im-o-app__left-top, .im-o-app__left-bottom')
26
+
27
+ const insetTop = isBottomSlot ? 'auto' : `${Math.round(buttonRect.top - mainRect.top)}px`
28
+ const insetBottom = isBottomSlot ? `${Math.round(mainRect.bottom - buttonRect.bottom)}px` : 'auto'
29
+ const insetRight = isLeftSlot ? 'auto' : `${Math.round(mainRect.right - buttonRect.left + dividerGap)}px`
30
+ const insetLeft = isLeftSlot ? `${Math.round(buttonRect.right - mainRect.left + dividerGap)}px` : 'auto'
31
+ const anchor = isBottomSlot ? Math.round(mainRect.bottom - buttonRect.bottom) : Math.round(buttonRect.top - mainRect.top)
32
+
33
+ root.style.setProperty(MODAL_INSET, `${insetTop} ${insetRight} ${insetBottom} ${insetLeft}`)
34
+ root.style.setProperty(MODAL_MAX_HEIGHT, `${mainRect.height - anchor - dividerGap}px`)
35
+ }
36
+
37
+ const setSlotCSSVar = (slot, layoutRefs, primaryMargin) => {
38
+ const root = document.documentElement
39
+
40
+ // Left/right slots: delegate entirely to existing layout CSS vars
41
+ const mapped = SLOT_MODAL_VARS[slot]
42
+ if (mapped) {
43
+ root.style.setProperty(MODAL_INSET, mapped.inset)
44
+ root.style.setProperty(MODAL_MAX_HEIGHT, mapped.maxHeight)
45
+ return
46
+ }
47
+
48
+ // Other slots (e.g. inset): measure position from DOM
49
+ const refKey = `${slot[0].toLowerCase() + slot.slice(1)}Ref` // single-part slots only
50
+ const slotRef = layoutRefs[refKey]?.current
51
+ const mainContainer = layoutRefs.mainRef?.current
52
+ if (!slotRef || !mainContainer) {
53
+ return
54
+ }
55
+
56
+ const slotRect = slotRef.getBoundingClientRect()
57
+ const mainRect = mainContainer.getBoundingClientRect()
58
+ const relLeft = slotRect.left - mainRect.left
59
+ const relTop = slotRect.top - mainRect.top
60
+
61
+ root.style.setProperty(MODAL_INSET, `${relTop}px auto auto ${relLeft}px`)
62
+ root.style.setProperty(MODAL_MAX_HEIGHT, `${mainRect.height - relTop - primaryMargin}px`)
63
+ }
5
64
 
6
65
  const useModalKeyHandler = (panelRef, isModal, handleClose) => {
7
66
  useEffect(() => {
@@ -68,21 +127,43 @@ export function useModalPanelBehaviour ({
68
127
  buttonContainerEl,
69
128
  handleClose
70
129
  }) {
71
- useModalKeyHandler(panelRef, isModal, handleClose)
130
+ const { layoutRefs } = useApp()
72
131
 
73
- // === Set absolute offset positions and recalculate on mainRef resize === //
74
- const root = document.documentElement
75
- const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
132
+ useModalKeyHandler(panelRef, isModal, handleClose)
76
133
 
134
+ // === Set --modal-inset and --modal-max-height, recalculate on mainRef resize === //
77
135
  useResizeObserver([mainRef], () => {
78
- if (!isModal || !buttonContainerEl || !mainRef.current) {
136
+ if (!isModal || !mainRef.current) {
79
137
  return
80
138
  }
81
- const mainRect = mainRef.current.getBoundingClientRect()
82
- const buttonRect = buttonContainerEl.getBoundingClientRect()
83
- const offsetTop = buttonRect.top - mainRect.top
84
- const offsetRight = Math.round(mainRect.right - buttonRect.right + buttonRect.width + dividerGap)
85
- root.style.setProperty('--modal-inset', `${offsetTop}px ${offsetRight}px auto auto`)
139
+
140
+ const root = document.documentElement
141
+ const styles = getComputedStyle(root)
142
+ const dividerGap = Number.parseInt(styles.getPropertyValue('--divider-gap'), 10)
143
+ const primaryMargin = Number.parseInt(styles.getPropertyValue('--primary-gap'), 10)
144
+ const slot = panelRef.current.dataset.slot
145
+
146
+ // Button-adjacent panels: position next to the controlling button.
147
+ // Use slot name (not buttonContainerEl) as the gate — buttonContainerEl may be undefined
148
+ // when there is no triggeringElement (e.g. panel opened programmatically).
149
+ // Dynamically query via aria-controls to handle stale triggeringElement after breakpoint changes.
150
+ if (slot?.endsWith('-button')) {
151
+ const panelElId = panelRef.current?.id
152
+ const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
153
+ const effectiveContainer = currentButtonEl?.parentElement ??
154
+ (buttonContainerEl?.isConnected ? buttonContainerEl : null) ??
155
+ document.querySelector(`[data-button-slot="${slot}"]`)
156
+
157
+ if (!effectiveContainer) {
158
+ return
159
+ }
160
+
161
+ setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
162
+ return
163
+ }
164
+
165
+ // Slot-based panels: derive position from the slot container element
166
+ setSlotCSSVar(slot, layoutRefs, primaryMargin)
86
167
  })
87
168
 
88
169
  // === Click on modal backdrop to close === //