@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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/plugins/plugin-descriptor.md +37 -0
- package/package.json +15 -6
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/src/events.js +4 -14
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +1 -3
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +28 -6
- package/plugins/interact/src/InteractInit.test.js +19 -5
- package/plugins/interact/src/events.js +17 -15
- package/plugins/interact/src/events.test.js +25 -16
- 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 +9 -6
- package/providers/beta/esri/dist/css/index.css +4 -0
- package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
- package/providers/beta/esri/src/esriProvider.js +19 -3
- package/providers/beta/esri/src/esriProvider.scss +5 -0
- package/providers/beta/esri/src/mapEvents.js +34 -3
- package/providers/beta/esri/src/utils/coords.js +1 -0
- package/providers/beta/esri/src/utils/spatial.js +47 -1
- package/providers/beta/esri/src/utils/spatial.test.js +55 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/src/maplibreProvider.js +12 -1
- package/providers/maplibre/src/maplibreProvider.test.js +14 -1
- package/providers/maplibre/src/utils/spatial.js +40 -0
- package/providers/maplibre/src/utils/spatial.test.js +35 -0
- package/src/App/components/MapButton/MapButton.jsx +1 -0
- package/src/App/components/Panel/Panel.jsx +14 -13
- package/src/App/components/Viewport/MapController.jsx +4 -0
- package/src/App/hooks/useLayoutMeasurements.js +37 -20
- package/src/App/hooks/useLayoutMeasurements.test.js +38 -6
- package/src/App/hooks/useMarkersAPI.js +5 -3
- package/src/App/hooks/useModalPanelBehaviour.js +91 -10
- package/src/App/hooks/useModalPanelBehaviour.test.js +185 -53
- package/src/App/hooks/useVisibleGeometry.js +100 -0
- package/src/App/hooks/useVisibleGeometry.test.js +331 -0
- package/src/App/layout/Layout.jsx +13 -5
- package/src/App/layout/layout.module.scss +149 -13
- package/src/App/renderer/HtmlElementHost.jsx +10 -2
- package/src/App/renderer/HtmlElementHost.test.jsx +12 -0
- package/src/App/renderer/SlotRenderer.jsx +1 -1
- package/src/App/renderer/mapPanels.js +1 -2
- package/src/App/renderer/pluginWrapper.js +3 -2
- package/src/App/renderer/slots.js +12 -6
- package/src/App/store/AppProvider.jsx +6 -1
- package/src/App/store/appDispatchMiddleware.js +19 -0
- package/src/App/store/appDispatchMiddleware.test.js +56 -0
- package/src/InteractiveMap/InteractiveMap.js +3 -3
- package/src/types.js +9 -0
- package/src/utils/getSafeZoneInset.js +12 -9
- 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
|
|
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'
|
|
@@ -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 (
|
|
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
|
-
// ===
|
|
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`)
|
|
58
|
+
// === Top column width ===
|
|
59
|
+
appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
|
|
48
60
|
|
|
49
|
-
// ===
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
appContainer.style.setProperty('--offset-
|
|
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
|
|
70
|
+
const rightColumnHeight = bottom.offsetTop - rightOffsetTop - dividerGap
|
|
58
71
|
appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
|
|
59
|
-
appContainer.style.setProperty('--right-offset-bottom', `${
|
|
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
|
-
// ===
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
appContainer.style.setProperty('--
|
|
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
|
-
;['--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
if (!isModal || !
|
|
136
|
+
if (!isModal || !mainRef.current) {
|
|
79
137
|
return
|
|
80
138
|
}
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
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 === //
|