@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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/package.json +9 -4
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/src/manifest.js +4 -4
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/src/manifest.js +2 -2
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/events/fetchSuggestions.js +10 -7
- package/plugins/search/src/events/fetchSuggestions.test.js +4 -4
- package/plugins/search/src/search.scss +8 -3
- package/providers/beta/esri/dist/css/index.css +4 -0
- package/providers/beta/esri/src/esriProvider.scss +5 -0
- package/src/App/components/MapButton/MapButton.jsx +1 -0
- package/src/App/components/Panel/Panel.jsx +14 -13
- package/src/App/components/Panel/Panel.module.scss +1 -0
- package/src/App/hooks/useLayoutMeasurements.js +31 -23
- package/src/App/hooks/useLayoutMeasurements.test.js +39 -10
- package/src/App/hooks/useModalPanelBehaviour.js +85 -21
- package/src/App/hooks/useModalPanelBehaviour.test.js +126 -18
- package/src/App/hooks/useVisibleGeometry.js +7 -13
- package/src/App/hooks/useVisibleGeometry.test.js +72 -47
- package/src/App/layout/Layout.jsx +11 -6
- package/src/App/layout/Layout.test.jsx +0 -1
- package/src/App/layout/layout.module.scss +83 -10
- package/src/App/renderer/HtmlElementHost.jsx +10 -4
- package/src/App/renderer/HtmlElementHost.test.jsx +32 -11
- package/src/App/renderer/SlotRenderer.jsx +1 -1
- package/src/App/renderer/mapPanels.js +1 -2
- package/src/App/renderer/mapPanels.test.js +3 -3
- package/src/App/renderer/slotHelpers.js +2 -2
- package/src/App/renderer/slotHelpers.test.js +3 -3
- package/src/App/renderer/slots.js +11 -8
- package/src/App/store/AppProvider.jsx +5 -2
- package/src/App/store/appDispatchMiddleware.test.js +2 -2
- package/src/config/appConfig.js +4 -4
- package/src/utils/getSafeZoneInset.js +139 -39
- package/src/utils/getSafeZoneInset.test.js +301 -81
|
@@ -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
|
|
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,
|
|
16
|
+
return { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const getPanelRole = (isDialog,
|
|
19
|
+
const getPanelRole = (isDialog, isDismissible) => {
|
|
20
20
|
if (isDialog) {
|
|
21
21
|
return 'dialog'
|
|
22
22
|
}
|
|
23
|
-
if (
|
|
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,
|
|
35
|
+
const buildPanelBodyClassNames = (showLabel, isDismissible) => [
|
|
36
36
|
'im-c-panel__body',
|
|
37
|
-
!showLabel &&
|
|
37
|
+
!showLabel && isDismissible && 'im-c-panel__body--offset'
|
|
38
38
|
].filter(Boolean).join(' ')
|
|
39
39
|
|
|
40
|
-
const buildPanelProps = ({ elementId, shouldFocus, isDialog,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
{
|
|
119
|
+
{isDismissible && (
|
|
119
120
|
<button
|
|
120
121
|
aria-label={`Close ${label}`}
|
|
121
122
|
className='im-c-panel__close'
|
|
@@ -4,6 +4,14 @@ 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
|
+
|
|
7
15
|
export function useLayoutMeasurements () {
|
|
8
16
|
const { dispatch, breakpoint, layoutRefs } = useApp()
|
|
9
17
|
const { mapSize, isMapReady } = useMap()
|
|
@@ -15,9 +23,12 @@ export function useLayoutMeasurements () {
|
|
|
15
23
|
topRef,
|
|
16
24
|
topLeftColRef,
|
|
17
25
|
topRightColRef,
|
|
18
|
-
insetRef,
|
|
19
26
|
footerRef,
|
|
20
|
-
actionsRef
|
|
27
|
+
actionsRef,
|
|
28
|
+
leftTopRef,
|
|
29
|
+
leftBottomRef,
|
|
30
|
+
rightTopRef,
|
|
31
|
+
rightBottomRef
|
|
21
32
|
} = layoutRefs
|
|
22
33
|
|
|
23
34
|
// -----------------------------
|
|
@@ -29,40 +40,37 @@ export function useLayoutMeasurements () {
|
|
|
29
40
|
const top = topRef.current
|
|
30
41
|
const topLeftCol = topLeftColRef.current
|
|
31
42
|
const topRightCol = topRightColRef.current
|
|
32
|
-
const inset = insetRef.current
|
|
33
43
|
const bottom = footerRef.current
|
|
34
|
-
const actions = actionsRef.current
|
|
35
44
|
|
|
36
|
-
if (
|
|
45
|
+
if ([main, top, bottom].some(r => !r)) {
|
|
37
46
|
return
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
const root = document.documentElement
|
|
41
50
|
const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
|
|
42
51
|
|
|
43
|
-
// ===
|
|
44
|
-
|
|
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`)
|
|
52
|
+
// === Top column width ===
|
|
53
|
+
appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
|
|
48
54
|
|
|
49
|
-
// ===
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
appContainer.style.setProperty('--offset-
|
|
55
|
+
// === Left container offsets ===
|
|
56
|
+
const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
|
|
57
|
+
const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
|
|
58
|
+
appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
|
|
59
|
+
appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
|
|
60
|
+
appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
|
|
54
61
|
|
|
55
62
|
// === Right container offsets ===
|
|
56
63
|
const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
|
|
57
|
-
const
|
|
64
|
+
const rightColumnHeight = bottom.offsetTop - rightOffsetTop - dividerGap
|
|
58
65
|
appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
|
|
59
|
-
appContainer.style.setProperty('--right-offset-bottom', `${
|
|
66
|
+
appContainer.style.setProperty('--right-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
|
|
67
|
+
appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
|
|
60
68
|
|
|
61
|
-
// ===
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
appContainer.style.setProperty('--
|
|
69
|
+
// === Sub-slot panel max-heights ===
|
|
70
|
+
appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
|
|
71
|
+
appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
|
|
72
|
+
appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
|
|
73
|
+
appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
// --------------------------------
|
|
@@ -81,7 +89,7 @@ export function useLayoutMeasurements () {
|
|
|
81
89
|
// --------------------------------
|
|
82
90
|
// 3. Recaluclate CSS vars when elements resize
|
|
83
91
|
// --------------------------------
|
|
84
|
-
useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, footerRef], () => {
|
|
92
|
+
useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, footerRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef], () => {
|
|
85
93
|
requestAnimationFrame(() => {
|
|
86
94
|
calculateLayout()
|
|
87
95
|
})
|
|
@@ -24,9 +24,12 @@ const refs = (o = {}) => ({
|
|
|
24
24
|
topRef: { current: o.top === null ? null : el({ offsetTop: 10, ...o.top }) },
|
|
25
25
|
topLeftColRef: { current: el({ offsetHeight: 50, offsetWidth: 200, ...o.topLeftCol }) },
|
|
26
26
|
topRightColRef: { current: el({ offsetHeight: 40, offsetWidth: 180, ...o.topRightCol }) },
|
|
27
|
-
insetRef: { current: o.inset === null ? null : el({ offsetHeight: 100, offsetLeft: 20, offsetWidth: 300, ...o.inset }) },
|
|
28
27
|
footerRef: { current: o.footer === null ? null : el({ offsetTop: 400, ...o.footer }) },
|
|
29
|
-
actionsRef: { current: el({ offsetTop: 450, ...o.actions }) }
|
|
28
|
+
actionsRef: { current: el({ offsetTop: 450, ...o.actions }) },
|
|
29
|
+
leftTopRef: { current: el({ offsetHeight: 0, ...o.leftTop }) },
|
|
30
|
+
leftBottomRef: { current: el({ offsetHeight: 0, ...o.leftBottom }) },
|
|
31
|
+
rightTopRef: { current: el({ offsetHeight: 0, ...o.rightTop }) },
|
|
32
|
+
rightBottomRef: { current: el({ offsetHeight: 0, ...o.rightBottom }) }
|
|
30
33
|
})
|
|
31
34
|
|
|
32
35
|
const setup = (o = {}) => {
|
|
@@ -53,7 +56,7 @@ describe('useLayoutMeasurements', () => {
|
|
|
53
56
|
})
|
|
54
57
|
|
|
55
58
|
test('early return when required refs are null', () => {
|
|
56
|
-
const { layoutRefs } = setup({ refs: { main: null, top: null,
|
|
59
|
+
const { layoutRefs } = setup({ refs: { main: null, top: null, footer: null } })
|
|
57
60
|
renderHook(() => useLayoutMeasurements())
|
|
58
61
|
expect(layoutRefs.appContainerRef.current.style.setProperty).not.toHaveBeenCalled()
|
|
59
62
|
})
|
|
@@ -62,17 +65,16 @@ describe('useLayoutMeasurements', () => {
|
|
|
62
65
|
const { layoutRefs } = setup()
|
|
63
66
|
renderHook(() => useLayoutMeasurements())
|
|
64
67
|
const spy = layoutRefs.appContainerRef.current.style.setProperty
|
|
65
|
-
;['--
|
|
68
|
+
;['--right-offset-top', '--right-offset-bottom', '--top-col-width']
|
|
66
69
|
.forEach(prop => expect(spy).toHaveBeenCalledWith(prop, expect.any(String)))
|
|
67
70
|
})
|
|
68
71
|
|
|
69
72
|
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
|
-
['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
|
-
['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
73
|
['right-offset-top', { topRightCol: { offsetHeight: 80 }, top: { offsetTop: 15 } }, '95px'],
|
|
75
|
-
['right-offset-bottom', { main: { offsetHeight: 600 }, footer: { offsetTop: 500 } }, '108px']
|
|
74
|
+
['right-offset-bottom', { main: { offsetHeight: 600 }, footer: { offsetTop: 500 } }, '108px'],
|
|
75
|
+
// leftColumnHeight = 400 - (50+10) - 8 = 332; rightColumnHeight = 400 - (40+10) - 8 = 342
|
|
76
|
+
['left-top-max-height', {}, '332px'],
|
|
77
|
+
['right-top-max-height', {}, '342px']
|
|
76
78
|
])('calculates %s correctly', (name, refOverrides, expected) => {
|
|
77
79
|
const { layoutRefs } = setup({ refs: refOverrides })
|
|
78
80
|
renderHook(() => useLayoutMeasurements())
|
|
@@ -80,6 +82,21 @@ describe('useLayoutMeasurements', () => {
|
|
|
80
82
|
expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith(varName, expected)
|
|
81
83
|
})
|
|
82
84
|
|
|
85
|
+
test.each([
|
|
86
|
+
['--left-top-panel-max-height', {}, '332px'],
|
|
87
|
+
['--left-top-panel-max-height', { leftBottom: { offsetHeight: 50 } }, '274px'], // 332 - 50 - 8
|
|
88
|
+
['--left-bottom-panel-max-height', {}, '332px'],
|
|
89
|
+
['--left-bottom-panel-max-height', { leftTop: { offsetHeight: 40 } }, '284px'], // 332 - 40 - 8
|
|
90
|
+
['--right-top-panel-max-height', {}, '342px'],
|
|
91
|
+
['--right-top-panel-max-height', { rightBottom: { offsetHeight: 60 } }, '274px'], // 342 - 60 - 8
|
|
92
|
+
['--right-bottom-panel-max-height', {}, '342px'],
|
|
93
|
+
['--right-bottom-panel-max-height', { rightTop: { offsetHeight: 30 } }, '304px'] // 342 - 30 - 8
|
|
94
|
+
])('calculates %s with sibling buttons=%o correctly', (varName, refOverrides, expected) => {
|
|
95
|
+
const { layoutRefs } = setup({ refs: refOverrides })
|
|
96
|
+
renderHook(() => useLayoutMeasurements())
|
|
97
|
+
expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith(varName, expected)
|
|
98
|
+
})
|
|
99
|
+
|
|
83
100
|
test.each([
|
|
84
101
|
[{ offsetWidth: 250 }, { offsetWidth: 200 }, '250px'],
|
|
85
102
|
[{ offsetWidth: 0 }, { offsetWidth: 200 }, '200px'],
|
|
@@ -90,6 +107,18 @@ describe('useLayoutMeasurements', () => {
|
|
|
90
107
|
expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--top-col-width', expected)
|
|
91
108
|
})
|
|
92
109
|
|
|
110
|
+
test('uses 0 when sub-slot refs have null current', () => {
|
|
111
|
+
const { layoutRefs } = setup()
|
|
112
|
+
layoutRefs.leftTopRef.current = null
|
|
113
|
+
layoutRefs.leftBottomRef.current = null
|
|
114
|
+
layoutRefs.rightTopRef.current = null
|
|
115
|
+
layoutRefs.rightBottomRef.current = null
|
|
116
|
+
renderHook(() => useLayoutMeasurements())
|
|
117
|
+
// With all sub-slot refs null, buttons = 0 ?? 0 = 0, so max-heights equal full column height
|
|
118
|
+
expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--left-top-panel-max-height', '332px')
|
|
119
|
+
expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--right-bottom-panel-max-height', '342px')
|
|
120
|
+
})
|
|
121
|
+
|
|
93
122
|
test('dispatches safe zone inset', () => {
|
|
94
123
|
const { dispatch, layoutRefs } = setup()
|
|
95
124
|
getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 15, left: 5 })
|
|
@@ -114,7 +143,7 @@ describe('useLayoutMeasurements', () => {
|
|
|
114
143
|
const { layoutRefs } = setup()
|
|
115
144
|
renderHook(() => useLayoutMeasurements())
|
|
116
145
|
expect(useResizeObserver).toHaveBeenCalledWith(
|
|
117
|
-
[layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.footerRef],
|
|
146
|
+
[layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.footerRef, layoutRefs.leftTopRef, layoutRefs.leftBottomRef, layoutRefs.rightTopRef, layoutRefs.rightBottomRef],
|
|
118
147
|
expect.any(Function)
|
|
119
148
|
)
|
|
120
149
|
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
|
-
|
|
130
|
+
const { layoutRefs } = useApp()
|
|
72
131
|
|
|
73
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
161
|
+
setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
|
|
95
162
|
return
|
|
96
163
|
}
|
|
97
164
|
|
|
98
|
-
|
|
99
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
expect(
|
|
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(
|
|
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
|
-
|
|
113
|
-
expect(
|
|
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
|
-
|
|
119
|
-
|
|
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(
|