@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.
@@ -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'
@@ -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()
@@ -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,38 +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
136
  if (!isModal || !mainRef.current) {
79
137
  return
80
138
  }
81
139
 
82
- // buttonContainerEl is only defined for button-slot panels (bpConfig.slot ends with '-button').
83
- // Skip positioning for all other modal types.
84
- if (buttonContainerEl === undefined) {
85
- return
86
- }
87
-
88
- // Dynamically query the current controlling button via aria-controls to handle the case
89
- // where the button has remounted after a breakpoint change (stale triggeringElement).
90
- const panelElId = panelRef.current?.id
91
- const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
92
- const effectiveContainer = currentButtonEl?.parentElement ?? (buttonContainerEl?.isConnected ? buttonContainerEl : null)
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
+ }
93
160
 
94
- if (!effectiveContainer) {
161
+ setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
95
162
  return
96
163
  }
97
164
 
98
- const mainRect = mainRef.current.getBoundingClientRect()
99
- const buttonRect = effectiveContainer.getBoundingClientRect()
100
- const offsetTop = buttonRect.top - mainRect.top
101
- const offsetRight = Math.round(mainRect.right - buttonRect.right + buttonRect.width + dividerGap)
102
- root.style.setProperty('--modal-inset', `${offsetTop}px ${offsetRight}px auto auto`)
165
+ // Slot-based panels: derive position from the slot container element
166
+ setSlotCSSVar(slot, layoutRefs, primaryMargin)
103
167
  })
104
168
 
105
169
  // === Click on modal backdrop to close === //
@@ -4,10 +4,17 @@ import { useModalPanelBehaviour } from './useModalPanelBehaviour.js'
4
4
  import * as useResizeObserverModule from './useResizeObserver.js'
5
5
  import * as constrainFocusModule from '../../utils/constrainKeyboardFocus.js'
6
6
  import * as toggleInertModule from '../../utils/toggleInertElements.js'
7
+ import { useApp } from '../store/appContext.js'
7
8
 
8
9
  jest.mock('./useResizeObserver.js')
9
10
  jest.mock('../../utils/constrainKeyboardFocus.js')
10
11
  jest.mock('../../utils/toggleInertElements.js')
12
+ jest.mock('../store/appContext.js')
13
+
14
+ const MODAL_INSET = '--modal-inset'
15
+ const MODAL_MAX_HEIGHT = '--modal-max-height'
16
+ const PANEL_ID = 'modal-panel-id'
17
+ const ARIA_CONTROLS = 'aria-controls'
11
18
 
12
19
  describe('useModalPanelBehaviour', () => {
13
20
  let refs, elements, handleClose
@@ -17,8 +24,9 @@ describe('useModalPanelBehaviour', () => {
17
24
  main: { current: document.createElement('div') },
18
25
  panel: { current: document.createElement('div') }
19
26
  }
20
- // Give panel an ID for aria-controls tests
21
- refs.panel.current.id = 'modal-panel-id'
27
+ // Give panel an ID for aria-controls tests and a slot for setSlotCSSVar
28
+ refs.panel.current.id = PANEL_ID
29
+ refs.panel.current.dataset.slot = 'inset'
22
30
 
23
31
  elements = {
24
32
  buttonContainer: document.createElement('div'),
@@ -30,7 +38,9 @@ describe('useModalPanelBehaviour', () => {
30
38
 
31
39
  handleClose = jest.fn()
32
40
  jest.clearAllMocks()
33
- document.documentElement.style.setProperty('--modal-inset', '')
41
+ document.documentElement.style.setProperty(MODAL_INSET, '')
42
+ document.documentElement.style.setProperty(MODAL_MAX_HEIGHT, '')
43
+ useApp.mockReturnValue({ layoutRefs: {} })
34
44
  })
35
45
 
36
46
  afterEach(() => {
@@ -72,10 +82,13 @@ describe('useModalPanelBehaviour', () => {
72
82
  )
73
83
  })
74
84
 
75
- describe('positioning (--modal-inset)', () => {
85
+ describe('positioning (--modal-inset, --modal-max-height)', () => {
86
+ const buttonSlot = 'map-styles-button'
87
+
76
88
  beforeEach(() => {
77
89
  // Force ResizeObserver to run the callback immediately
78
90
  useResizeObserverModule.useResizeObserver.mockImplementation((_, cb) => cb())
91
+ jest.spyOn(globalThis, 'getComputedStyle').mockReturnValue({ getPropertyValue: () => '8' })
79
92
 
80
93
  Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
81
94
  value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 }),
@@ -87,36 +100,121 @@ describe('useModalPanelBehaviour', () => {
87
100
  })
88
101
  })
89
102
 
90
- it('hits the buttonContainerEl === undefined branch', () => {
91
- refs.main.current = document.createElement('div') // mainRef must exist
103
+ afterEach(() => {
104
+ jest.restoreAllMocks()
105
+ })
106
+
107
+ it('sets --modal-inset via SLOT_MODAL_VARS for top slot', () => {
108
+ refs.panel.current.dataset.slot = 'left-top'
109
+ render(<TestComponent />)
110
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET))
111
+ .toBe('var(--left-offset-top) auto auto var(--primary-gap)')
112
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT))
113
+ .toBe('var(--left-top-max-height)')
114
+ })
115
+
116
+ it('sets --modal-inset via SLOT_MODAL_VARS for bottom slot', () => {
117
+ refs.panel.current.dataset.slot = 'left-bottom'
118
+ render(<TestComponent />)
119
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET))
120
+ .toBe('auto auto var(--left-offset-bottom) var(--primary-gap)')
121
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT))
122
+ .toBe('var(--left-top-max-height)')
123
+ })
124
+
125
+ it('sets --modal-inset and --modal-max-height from slot container when no buttonContainerEl', () => {
126
+ const insetEl = document.createElement('div')
127
+ Object.defineProperty(insetEl, 'getBoundingClientRect', {
128
+ value: () => ({ top: 60, left: 8, right: 200, bottom: 200 }),
129
+ configurable: true
130
+ })
131
+ Object.defineProperty(insetEl, 'offsetWidth', { value: 192, configurable: true })
132
+ useApp.mockReturnValue({ layoutRefs: { insetRef: { current: insetEl }, mainRef: refs.main } })
133
+
92
134
  render(<TestComponent />)
93
135
 
94
- // Manually trigger ResizeObserver callback (if mocked)
95
- const callback = useResizeObserverModule.useResizeObserver.mock.calls[0][1]
96
- callback()
136
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('60px auto auto 8px')
137
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
138
+ })
97
139
 
98
- // Expect CSS variable not set, just to assert callback ran
99
- const inset = document.documentElement.style.getPropertyValue('--modal-inset')
100
- expect(inset).toBe('')
140
+ it('leaves --modal-inset unset when slot ref cannot be resolved', () => {
141
+ render(<TestComponent />)
142
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('')
101
143
  })
102
144
 
103
- it('updates --modal-inset via aria-controls when buttonContainerEl is stale', () => {
145
+ it('updates --modal-inset and --modal-max-height via aria-controls when buttonContainerEl is stale', () => {
146
+ refs.panel.current.dataset.slot = buttonSlot
147
+
104
148
  const button = document.createElement('button')
105
- button.setAttribute('aria-controls', 'modal-panel-id')
149
+ button.setAttribute(ARIA_CONTROLS, PANEL_ID)
106
150
  elements.buttonContainer.appendChild(button)
107
151
  document.body.appendChild(elements.buttonContainer)
108
152
 
109
153
  const staleEl = document.createElement('div') // detached
110
154
  render(<TestComponent buttonContainerEl={staleEl} />)
111
155
 
112
- const inset = document.documentElement.style.getPropertyValue('--modal-inset')
113
- expect(inset).toContain('10px')
156
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
157
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
158
+ })
159
+
160
+ it('uses data-button-slot fallback when no aria-controls button and no buttonContainerEl', () => {
161
+ refs.panel.current.dataset.slot = buttonSlot
162
+ elements.buttonContainer.dataset.buttonSlot = buttonSlot
163
+ document.body.appendChild(elements.buttonContainer)
164
+
165
+ render(<TestComponent buttonContainerEl={undefined} />)
166
+
167
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
168
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
169
+ })
170
+
171
+ it('uses connected buttonContainerEl when panel has no ID', () => {
172
+ refs.panel.current.id = '' // falsy panelElId → currentButtonEl = null (line 152 false branch)
173
+ refs.panel.current.dataset.slot = buttonSlot
174
+ document.body.appendChild(elements.buttonContainer) // isConnected = true (line 154 true branch)
175
+
176
+ render(<TestComponent buttonContainerEl={elements.buttonContainer} />)
177
+
178
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
114
179
  })
115
180
 
116
181
  it('skips update when effectiveContainer cannot be resolved', () => {
182
+ refs.panel.current.dataset.slot = buttonSlot
117
183
  render(<TestComponent buttonContainerEl={null} />)
118
- const inset = document.documentElement.style.getPropertyValue('--modal-inset')
119
- expect(inset).toBe('')
184
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('')
185
+ })
186
+
187
+ it('anchors to bottom when button is in a bottom sub-slot', () => {
188
+ refs.panel.current.dataset.slot = buttonSlot
189
+ const button = document.createElement('button')
190
+ button.setAttribute(ARIA_CONTROLS, PANEL_ID)
191
+ elements.buttonContainer.appendChild(button)
192
+ const bottomSlot = document.createElement('div')
193
+ bottomSlot.className = 'im-o-app__right-bottom'
194
+ bottomSlot.appendChild(elements.buttonContainer)
195
+ document.body.appendChild(bottomSlot)
196
+
197
+ render(<TestComponent />)
198
+
199
+ // Bottom slot: insetTop='auto', insetBottom = mainRect.bottom - buttonRect.bottom = 50 - 40 = 10px
200
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toMatch(/^auto/)
201
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
202
+ })
203
+
204
+ it('uses left inset when button is in a left sub-slot', () => {
205
+ refs.panel.current.dataset.slot = buttonSlot
206
+ const button = document.createElement('button')
207
+ button.setAttribute(ARIA_CONTROLS, PANEL_ID)
208
+ elements.buttonContainer.appendChild(button)
209
+ const leftSlot = document.createElement('div')
210
+ leftSlot.className = 'im-o-app__left-top'
211
+ leftSlot.appendChild(elements.buttonContainer)
212
+ document.body.appendChild(leftSlot)
213
+
214
+ render(<TestComponent />)
215
+
216
+ // Left slot: insetTop = buttonRect.top - mainRect.top = 10, insetLeft = buttonRect.right - mainRect.left + dividerGap = 80 + 8 = 88
217
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('10px auto auto 88px')
120
218
  })
121
219
  })
122
220
 
@@ -175,6 +273,16 @@ describe('useModalPanelBehaviour', () => {
175
273
  expect(handleClose).toHaveBeenCalled()
176
274
  })
177
275
 
276
+ it('does not close when backdrop is outside rootEl', () => {
277
+ const externalBackdrop = document.createElement('div')
278
+ externalBackdrop.className = 'im-o-app__modal-backdrop'
279
+ document.body.appendChild(externalBackdrop)
280
+
281
+ render(<TestComponent />)
282
+ fireEvent.click(externalBackdrop)
283
+ expect(handleClose).not.toHaveBeenCalled()
284
+ })
285
+
178
286
  it('toggles inert elements on mount and cleanup', () => {
179
287
  const { unmount } = render(<TestComponent />)
180
288
  expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith(
@@ -66,17 +66,25 @@ export const Layout = () => {
66
66
  <div className='im-o-app__inset' ref={layoutRefs.insetRef}>
67
67
  <SlotRenderer slot={layoutSlots.INSET} />
68
68
  </div>
69
+ <div className='im-o-app__left' ref={layoutRefs.leftRef}>
70
+ <div className='im-o-app__left-top' ref={layoutRefs.leftTopRef}>
71
+ <SlotRenderer slot={layoutSlots.LEFT_TOP} />
72
+ </div>
73
+ <div className='im-o-app__left-bottom' ref={layoutRefs.leftBottomRef}>
74
+ <SlotRenderer slot={layoutSlots.LEFT_BOTTOM} />
75
+ </div>
76
+ </div>
77
+ <div className='im-o-app__middle' ref={layoutRefs.middleRef}>
78
+ <SlotRenderer slot={layoutSlots.MIDDLE} />
79
+ </div>
69
80
  <div className='im-o-app__right' ref={layoutRefs.rightRef}>
70
- <div className='im-o-app__right-top'>
81
+ <div className='im-o-app__right-top' ref={layoutRefs.rightTopRef}>
71
82
  <SlotRenderer slot={layoutSlots.RIGHT_TOP} />
72
83
  </div>
73
84
  <div className='im-o-app__right-bottom' ref={layoutRefs.rightBottomRef}>
74
85
  <SlotRenderer slot={layoutSlots.RIGHT_BOTTOM} />
75
86
  </div>
76
87
  </div>
77
- <div className='im-o-app__middle' ref={layoutRefs.middleRef}>
78
- <SlotRenderer slot={layoutSlots.MIDDLE} />
79
- </div>
80
88
  <div className='im-o-app__footer' ref={layoutRefs.footerRef}>
81
89
  <div className='im-o-app__footer-col'>
82
90
  <Logo />