@defra/interactive-map 0.0.11-alpha → 0.0.12-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 +7 -3
- 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/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/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/Viewport/MapController.jsx +4 -0
- package/src/App/hooks/useMarkersAPI.js +5 -3
- package/src/App/hooks/useModalPanelBehaviour.js +19 -2
- package/src/App/hooks/useModalPanelBehaviour.test.js +84 -60
- package/src/App/hooks/useVisibleGeometry.js +100 -0
- package/src/App/hooks/useVisibleGeometry.test.js +331 -0
- package/src/App/layout/Layout.jsx +1 -1
- package/src/App/layout/layout.module.scss +1 -4
- package/src/App/renderer/HtmlElementHost.jsx +1 -0
- package/src/App/renderer/pluginWrapper.js +3 -2
- package/src/App/renderer/slots.js +1 -1
- package/src/App/store/AppProvider.jsx +1 -0
- 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
|
@@ -17,12 +17,17 @@ describe('useModalPanelBehaviour', () => {
|
|
|
17
17
|
main: { current: document.createElement('div') },
|
|
18
18
|
panel: { current: document.createElement('div') }
|
|
19
19
|
}
|
|
20
|
+
// Give panel an ID for aria-controls tests
|
|
21
|
+
refs.panel.current.id = 'modal-panel-id'
|
|
22
|
+
|
|
20
23
|
elements = {
|
|
21
24
|
buttonContainer: document.createElement('div'),
|
|
22
25
|
root: document.createElement('div')
|
|
23
26
|
}
|
|
27
|
+
|
|
24
28
|
elements.root.appendChild(refs.panel.current)
|
|
25
29
|
document.body.appendChild(elements.root)
|
|
30
|
+
|
|
26
31
|
handleClose = jest.fn()
|
|
27
32
|
jest.clearAllMocks()
|
|
28
33
|
document.documentElement.style.setProperty('--modal-inset', '')
|
|
@@ -32,13 +37,17 @@ describe('useModalPanelBehaviour', () => {
|
|
|
32
37
|
document.body.innerHTML = ''
|
|
33
38
|
})
|
|
34
39
|
|
|
35
|
-
const TestComponent = ({
|
|
40
|
+
const TestComponent = ({
|
|
41
|
+
isModal = true,
|
|
42
|
+
buttonContainerEl,
|
|
43
|
+
rootEl = elements.root
|
|
44
|
+
}) => {
|
|
36
45
|
useModalPanelBehaviour({
|
|
37
46
|
mainRef: refs.main,
|
|
38
47
|
panelRef: refs.panel,
|
|
39
48
|
isModal,
|
|
40
|
-
rootEl
|
|
41
|
-
buttonContainerEl
|
|
49
|
+
rootEl,
|
|
50
|
+
buttonContainerEl,
|
|
42
51
|
handleClose
|
|
43
52
|
})
|
|
44
53
|
return null
|
|
@@ -63,66 +72,51 @@ describe('useModalPanelBehaviour', () => {
|
|
|
63
72
|
)
|
|
64
73
|
})
|
|
65
74
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
describe('positioning (--modal-inset)', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
// Force ResizeObserver to run the callback immediately
|
|
78
|
+
useResizeObserverModule.useResizeObserver.mockImplementation((_, cb) => cb())
|
|
79
|
+
|
|
80
|
+
Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
|
|
81
|
+
value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 }),
|
|
82
|
+
configurable: true
|
|
83
|
+
})
|
|
84
|
+
Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
|
|
85
|
+
value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 }),
|
|
86
|
+
configurable: true
|
|
87
|
+
})
|
|
70
88
|
})
|
|
71
|
-
Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
|
|
72
|
-
value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 })
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
render(<TestComponent />)
|
|
76
|
-
|
|
77
|
-
const inset = getComputedStyle(document.documentElement).getPropertyValue('--modal-inset')
|
|
78
|
-
expect(inset).toContain('10px')
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
describe('backdrop clicks', () => {
|
|
82
|
-
const createBackdrop = (appendTo) => {
|
|
83
|
-
const backdrop = document.createElement('div')
|
|
84
|
-
backdrop.className = 'im-o-app__modal-backdrop'
|
|
85
|
-
appendTo.appendChild(backdrop)
|
|
86
|
-
return backdrop
|
|
87
|
-
}
|
|
88
89
|
|
|
89
|
-
it('
|
|
90
|
-
|
|
90
|
+
it('hits the buttonContainerEl === undefined branch', () => {
|
|
91
|
+
refs.main.current = document.createElement('div') // mainRef must exist
|
|
91
92
|
render(<TestComponent />)
|
|
92
|
-
fireEvent.click(backdrop)
|
|
93
|
-
expect(handleClose).toHaveBeenCalled()
|
|
94
|
-
})
|
|
95
93
|
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
fireEvent.click(backdrop)
|
|
100
|
-
expect(handleClose).not.toHaveBeenCalled()
|
|
101
|
-
})
|
|
94
|
+
// Manually trigger ResizeObserver callback (if mocked)
|
|
95
|
+
const callback = useResizeObserverModule.useResizeObserver.mock.calls[0][1]
|
|
96
|
+
callback()
|
|
102
97
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
fireEvent.click(elements.root.firstChild)
|
|
107
|
-
expect(handleClose).not.toHaveBeenCalled()
|
|
98
|
+
// Expect CSS variable not set, just to assert callback ran
|
|
99
|
+
const inset = document.documentElement.style.getPropertyValue('--modal-inset')
|
|
100
|
+
expect(inset).toBe('')
|
|
108
101
|
})
|
|
109
|
-
})
|
|
110
102
|
|
|
111
|
-
|
|
112
|
-
|
|
103
|
+
it('updates --modal-inset via aria-controls when buttonContainerEl is stale', () => {
|
|
104
|
+
const button = document.createElement('button')
|
|
105
|
+
button.setAttribute('aria-controls', 'modal-panel-id')
|
|
106
|
+
elements.buttonContainer.appendChild(button)
|
|
107
|
+
document.body.appendChild(elements.buttonContainer)
|
|
113
108
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
isFullscreen: true,
|
|
117
|
-
boundaryEl: elements.root
|
|
118
|
-
})
|
|
109
|
+
const staleEl = document.createElement('div') // detached
|
|
110
|
+
render(<TestComponent buttonContainerEl={staleEl} />)
|
|
119
111
|
|
|
120
|
-
|
|
112
|
+
const inset = document.documentElement.style.getPropertyValue('--modal-inset')
|
|
113
|
+
expect(inset).toContain('10px')
|
|
114
|
+
})
|
|
121
115
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
116
|
+
it('skips update when effectiveContainer cannot be resolved', () => {
|
|
117
|
+
render(<TestComponent buttonContainerEl={null} />)
|
|
118
|
+
const inset = document.documentElement.style.getPropertyValue('--modal-inset')
|
|
119
|
+
expect(inset).toBe('')
|
|
126
120
|
})
|
|
127
121
|
})
|
|
128
122
|
|
|
@@ -138,6 +132,20 @@ describe('useModalPanelBehaviour', () => {
|
|
|
138
132
|
expect(refs.panel.current.focus).toHaveBeenCalled()
|
|
139
133
|
})
|
|
140
134
|
|
|
135
|
+
// COVERS LINE 44 (The early return branch)
|
|
136
|
+
it('does not redirect focus when focus moves completely outside the app root', () => {
|
|
137
|
+
refs.panel.current.focus = jest.fn()
|
|
138
|
+
render(<TestComponent />)
|
|
139
|
+
|
|
140
|
+
const externalEl = document.createElement('button')
|
|
141
|
+
document.body.appendChild(externalEl) // Outside elements.root
|
|
142
|
+
|
|
143
|
+
dispatchFocusIn(externalEl)
|
|
144
|
+
|
|
145
|
+
// Since isInsideApp is false, it should hit the "return" and not call focus()
|
|
146
|
+
expect(refs.panel.current.focus).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
|
|
141
149
|
it('does not redirect focus when focus is already inside panel', () => {
|
|
142
150
|
refs.panel.current.focus = jest.fn()
|
|
143
151
|
render(<TestComponent />)
|
|
@@ -149,23 +157,39 @@ describe('useModalPanelBehaviour', () => {
|
|
|
149
157
|
expect(refs.panel.current.focus).not.toHaveBeenCalled()
|
|
150
158
|
})
|
|
151
159
|
|
|
152
|
-
it('handles
|
|
160
|
+
it('handles null focus targets gracefully', () => {
|
|
153
161
|
render(<TestComponent />)
|
|
154
|
-
|
|
155
162
|
dispatchFocusIn(null)
|
|
163
|
+
expect(true).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('backdrop and inert', () => {
|
|
168
|
+
it('calls handleClose when backdrop inside rootEl is clicked', () => {
|
|
169
|
+
const backdrop = document.createElement('div')
|
|
170
|
+
backdrop.className = 'im-o-app__modal-backdrop'
|
|
171
|
+
elements.root.appendChild(backdrop)
|
|
156
172
|
|
|
157
|
-
|
|
158
|
-
|
|
173
|
+
render(<TestComponent />)
|
|
174
|
+
fireEvent.click(backdrop)
|
|
175
|
+
expect(handleClose).toHaveBeenCalled()
|
|
176
|
+
})
|
|
159
177
|
|
|
160
|
-
|
|
178
|
+
it('toggles inert elements on mount and cleanup', () => {
|
|
179
|
+
const { unmount } = render(<TestComponent />)
|
|
180
|
+
expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith(
|
|
181
|
+
expect.objectContaining({ isFullscreen: true })
|
|
182
|
+
)
|
|
183
|
+
unmount()
|
|
184
|
+
expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith(
|
|
185
|
+
expect.objectContaining({ isFullscreen: false })
|
|
186
|
+
)
|
|
161
187
|
})
|
|
162
188
|
})
|
|
163
189
|
|
|
164
190
|
it('does nothing when isModal is false', () => {
|
|
165
191
|
render(<TestComponent isModal={false} />)
|
|
166
|
-
|
|
167
192
|
fireEvent.keyDown(refs.panel.current, { key: 'Escape' })
|
|
168
193
|
expect(handleClose).not.toHaveBeenCalled()
|
|
169
|
-
expect(toggleInertModule.toggleInertElements).not.toHaveBeenCalled()
|
|
170
194
|
})
|
|
171
195
|
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { useConfig } from '../store/configContext.js'
|
|
3
|
+
import { useApp } from '../store/appContext.js'
|
|
4
|
+
import { EVENTS as events } from '../../config/events.js'
|
|
5
|
+
|
|
6
|
+
export const getGeometryType = (geojson) => {
|
|
7
|
+
if (!geojson) {
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
if (geojson.type === 'Feature') {
|
|
11
|
+
return geojson.geometry?.type
|
|
12
|
+
}
|
|
13
|
+
return geojson.type
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const isPointGeometry = (geojson) => {
|
|
17
|
+
const type = getGeometryType(geojson)
|
|
18
|
+
return type === 'Point' || type === 'MultiPoint'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const getPointCoordinates = (geojson) => {
|
|
22
|
+
if (geojson.type === 'Feature') {
|
|
23
|
+
return getPointCoordinates(geojson.geometry)
|
|
24
|
+
}
|
|
25
|
+
if (geojson.type === 'Point') {
|
|
26
|
+
return geojson.coordinates
|
|
27
|
+
}
|
|
28
|
+
if (geojson.type === 'MultiPoint') {
|
|
29
|
+
return geojson.coordinates[0]
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SLOT_REFS = {
|
|
35
|
+
inset: 'insetRef',
|
|
36
|
+
bottom: 'bottomRef',
|
|
37
|
+
side: 'sideRef'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const useVisibleGeometry = () => {
|
|
41
|
+
const { mapProvider, eventBus } = useConfig()
|
|
42
|
+
const { layoutRefs, panelConfig, panelRegistry, breakpoint } = useApp()
|
|
43
|
+
|
|
44
|
+
const latestRef = useRef({ layoutRefs, panelConfig, panelRegistry, breakpoint })
|
|
45
|
+
latestRef.current = { layoutRefs, panelConfig, panelRegistry, breakpoint }
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!mapProvider || !eventBus) {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handlePanelOpened = ({ panelId, slot: eventSlot, visibleGeometry: eventVisibleGeometry }) => {
|
|
53
|
+
const { panelConfig: config, panelRegistry: registry, layoutRefs: refs, breakpoint: bp } = latestRef.current
|
|
54
|
+
const resolvedConfig = config?.[panelId] ? config : (registry?.getPanelConfig() ?? config)
|
|
55
|
+
const visibleGeometry = eventVisibleGeometry ?? resolvedConfig?.[panelId]?.visibleGeometry
|
|
56
|
+
const slot = eventSlot ?? resolvedConfig?.[panelId]?.[bp]?.slot
|
|
57
|
+
const slotRef = refs[SLOT_REFS[slot]]
|
|
58
|
+
|
|
59
|
+
if (!visibleGeometry || !slotRef) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (typeof mapProvider.isGeometryObscured !== 'function') {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const waitForPanel = () => {
|
|
67
|
+
const panelRect = slotRef.current?.getBoundingClientRect()
|
|
68
|
+
|
|
69
|
+
if (!panelRect || panelRect.width === 0 || panelRect.height === 0) {
|
|
70
|
+
// Not ready yet, check on the next animation frame
|
|
71
|
+
requestAnimationFrame(waitForPanel)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Panel now exists and has size, safe to measure
|
|
76
|
+
if (!mapProvider.isGeometryObscured(visibleGeometry, panelRect)) {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (isPointGeometry(visibleGeometry)) {
|
|
81
|
+
const center = getPointCoordinates(visibleGeometry)
|
|
82
|
+
if (center) {
|
|
83
|
+
mapProvider.setView({ center })
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
mapProvider.fitToBounds(visibleGeometry)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Start waiting for panel to exist with a measurable size
|
|
91
|
+
requestAnimationFrame(waitForPanel)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
eventBus.on(events.APP_PANEL_OPENED, handlePanelOpened)
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
eventBus.off(events.APP_PANEL_OPENED, handlePanelOpened)
|
|
98
|
+
}
|
|
99
|
+
}, [mapProvider, eventBus])
|
|
100
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react'
|
|
2
|
+
import { useVisibleGeometry, getGeometryType, getPointCoordinates } from './useVisibleGeometry'
|
|
3
|
+
import { useConfig } from '../store/configContext.js'
|
|
4
|
+
import { useApp } from '../store/appContext.js'
|
|
5
|
+
|
|
6
|
+
jest.mock('../store/configContext.js')
|
|
7
|
+
jest.mock('../store/appContext.js')
|
|
8
|
+
|
|
9
|
+
const pointFeature = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 51] }, properties: {} }
|
|
10
|
+
const multiPointFeature = { type: 'Feature', geometry: { type: 'MultiPoint', coordinates: [[1, 51], [2, 52]] }, properties: {} }
|
|
11
|
+
const polygonFeature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} }
|
|
12
|
+
|
|
13
|
+
const insetPanelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
|
|
14
|
+
const bottomPanelRect = { left: 0, top: 500, right: 1000, bottom: 800, width: 1000, height: 300 }
|
|
15
|
+
|
|
16
|
+
const setup = (overrides = {}) => {
|
|
17
|
+
const capturedHandlers = {}
|
|
18
|
+
const mapProvider = {
|
|
19
|
+
isGeometryObscured: jest.fn(() => true),
|
|
20
|
+
fitToBounds: jest.fn(),
|
|
21
|
+
setView: jest.fn(),
|
|
22
|
+
...overrides.mapProvider
|
|
23
|
+
}
|
|
24
|
+
const eventBus = {
|
|
25
|
+
on: jest.fn((event, handler) => { capturedHandlers[event] = handler }),
|
|
26
|
+
off: jest.fn(),
|
|
27
|
+
...overrides.eventBus
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const insetEl = document.createElement('div')
|
|
31
|
+
insetEl.getBoundingClientRect = jest.fn(() => insetPanelRect)
|
|
32
|
+
const bottomEl = document.createElement('div')
|
|
33
|
+
bottomEl.getBoundingClientRect = jest.fn(() => bottomPanelRect)
|
|
34
|
+
|
|
35
|
+
const layoutRefs = {
|
|
36
|
+
mainRef: { current: document.createElement('div') },
|
|
37
|
+
insetRef: { current: insetEl },
|
|
38
|
+
bottomRef: { current: bottomEl },
|
|
39
|
+
...overrides.layoutRefs
|
|
40
|
+
}
|
|
41
|
+
const panelConfig = {
|
|
42
|
+
myPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'inset' } },
|
|
43
|
+
emptyPanel: {},
|
|
44
|
+
...overrides.panelConfig
|
|
45
|
+
}
|
|
46
|
+
const panelRegistry = {
|
|
47
|
+
getPanelConfig: jest.fn(() => panelConfig),
|
|
48
|
+
...overrides.panelRegistry
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
useConfig.mockReturnValue({ mapProvider, eventBus, ...overrides.config })
|
|
52
|
+
useApp.mockReturnValue({ layoutRefs, panelConfig, panelRegistry, breakpoint: 'desktop', ...overrides.app })
|
|
53
|
+
|
|
54
|
+
return { mapProvider, eventBus, capturedHandlers, layoutRefs, panelConfig, insetEl, bottomEl }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('useVisibleGeometry', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
jest.clearAllMocks()
|
|
60
|
+
jest.useFakeTimers()
|
|
61
|
+
})
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
jest.useRealTimers()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('early returns when mapProvider is null', () => {
|
|
67
|
+
setup({ config: { mapProvider: null } })
|
|
68
|
+
const { result } = renderHook(() => useVisibleGeometry())
|
|
69
|
+
expect(result.error).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('early returns when eventBus is null', () => {
|
|
73
|
+
setup({ config: { eventBus: null } })
|
|
74
|
+
const { result } = renderHook(() => useVisibleGeometry())
|
|
75
|
+
expect(result.error).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('subscribes to APP_PANEL_OPENED on eventBus', () => {
|
|
79
|
+
const { eventBus } = setup()
|
|
80
|
+
renderHook(() => useVisibleGeometry())
|
|
81
|
+
expect(eventBus.on).toHaveBeenCalledWith('app:panelopened', expect.any(Function))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('unsubscribes from APP_PANEL_OPENED on unmount', () => {
|
|
85
|
+
const { eventBus } = setup()
|
|
86
|
+
const { unmount } = renderHook(() => useVisibleGeometry())
|
|
87
|
+
unmount()
|
|
88
|
+
expect(eventBus.off).toHaveBeenCalledWith('app:panelopened', expect.any(Function))
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('does nothing when panel has no visibleGeometry', () => {
|
|
92
|
+
const { mapProvider, capturedHandlers } = setup()
|
|
93
|
+
renderHook(() => useVisibleGeometry())
|
|
94
|
+
capturedHandlers['app:panelopened']({ panelId: 'emptyPanel' })
|
|
95
|
+
expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('does nothing when panel does not exist in config', () => {
|
|
99
|
+
const { mapProvider, capturedHandlers } = setup()
|
|
100
|
+
renderHook(() => useVisibleGeometry())
|
|
101
|
+
capturedHandlers['app:panelopened']({ panelId: 'unknownPanel' })
|
|
102
|
+
expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('does nothing when panel has visibleGeometry but no slot config', () => {
|
|
106
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
107
|
+
panelConfig: { noSlotPanel: { visibleGeometry: polygonFeature } }
|
|
108
|
+
})
|
|
109
|
+
renderHook(() => useVisibleGeometry())
|
|
110
|
+
capturedHandlers['app:panelopened']({ panelId: 'noSlotPanel' })
|
|
111
|
+
expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('does nothing when mapProvider has no isGeometryObscured method', () => {
|
|
115
|
+
const { capturedHandlers, mapProvider } = setup({ mapProvider: { isGeometryObscured: null, fitToBounds: jest.fn(), setView: jest.fn() } })
|
|
116
|
+
renderHook(() => useVisibleGeometry())
|
|
117
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
118
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('does nothing when slot ref has zero dimensions (panel not visible)', () => {
|
|
122
|
+
const zeroEl = document.createElement('div')
|
|
123
|
+
zeroEl.getBoundingClientRect = jest.fn(() => ({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }))
|
|
124
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
125
|
+
layoutRefs: {
|
|
126
|
+
mainRef: { current: document.createElement('div') },
|
|
127
|
+
insetRef: { current: zeroEl }
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
renderHook(() => useVisibleGeometry())
|
|
131
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
132
|
+
|
|
133
|
+
// Run the current pending animation frame
|
|
134
|
+
jest.runOnlyPendingTimers()
|
|
135
|
+
|
|
136
|
+
expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('does nothing when geometry is not obscured', () => {
|
|
140
|
+
const { mapProvider, capturedHandlers } = setup({ mapProvider: { isGeometryObscured: jest.fn(() => false), fitToBounds: jest.fn(), setView: jest.fn() } })
|
|
141
|
+
renderHook(() => useVisibleGeometry())
|
|
142
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
143
|
+
jest.runAllTimers()
|
|
144
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
145
|
+
expect(mapProvider.setView).not.toHaveBeenCalled()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('calls fitToBounds with visibleGeometry for non-point geometry when obscured', () => {
|
|
149
|
+
const { mapProvider, capturedHandlers } = setup()
|
|
150
|
+
renderHook(() => useVisibleGeometry())
|
|
151
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
152
|
+
jest.runAllTimers()
|
|
153
|
+
|
|
154
|
+
expect(mapProvider.isGeometryObscured).toHaveBeenCalledWith(polygonFeature, insetPanelRect)
|
|
155
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
|
|
156
|
+
expect(mapProvider.setView).not.toHaveBeenCalled()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('calls setView with center for Point geometry when obscured', () => {
|
|
160
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
161
|
+
panelConfig: { pointPanel: { visibleGeometry: pointFeature, desktop: { slot: 'inset' } } }
|
|
162
|
+
})
|
|
163
|
+
renderHook(() => useVisibleGeometry())
|
|
164
|
+
capturedHandlers['app:panelopened']({ panelId: 'pointPanel' })
|
|
165
|
+
jest.runAllTimers()
|
|
166
|
+
|
|
167
|
+
expect(mapProvider.setView).toHaveBeenCalledWith({ center: [1, 51] })
|
|
168
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('calls setView with first coordinate for MultiPoint geometry when obscured', () => {
|
|
172
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
173
|
+
panelConfig: { mpPanel: { visibleGeometry: multiPointFeature, desktop: { slot: 'inset' } } }
|
|
174
|
+
})
|
|
175
|
+
renderHook(() => useVisibleGeometry())
|
|
176
|
+
capturedHandlers['app:panelopened']({ panelId: 'mpPanel' })
|
|
177
|
+
jest.runAllTimers()
|
|
178
|
+
|
|
179
|
+
expect(mapProvider.setView).toHaveBeenCalledWith({ center: [1, 51] })
|
|
180
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('calls fitToBounds for a raw non-Feature geometry (e.g. Polygon) when obscured', () => {
|
|
184
|
+
const rawPolygon = { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }
|
|
185
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
186
|
+
panelConfig: { geoPanel: { visibleGeometry: rawPolygon, desktop: { slot: 'inset' } } }
|
|
187
|
+
})
|
|
188
|
+
renderHook(() => useVisibleGeometry())
|
|
189
|
+
capturedHandlers['app:panelopened']({ panelId: 'geoPanel' })
|
|
190
|
+
jest.runAllTimers()
|
|
191
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(rawPolygon)
|
|
192
|
+
expect(mapProvider.setView).not.toHaveBeenCalled()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('calls setView for a raw Point geometry (not Feature-wrapped) when obscured', () => {
|
|
196
|
+
const rawPoint = { type: 'Point', coordinates: [1, 51] }
|
|
197
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
198
|
+
panelConfig: { rawPointPanel: { visibleGeometry: rawPoint, desktop: { slot: 'inset' } } }
|
|
199
|
+
})
|
|
200
|
+
renderHook(() => useVisibleGeometry())
|
|
201
|
+
capturedHandlers['app:panelopened']({ panelId: 'rawPointPanel' })
|
|
202
|
+
jest.runAllTimers()
|
|
203
|
+
expect(mapProvider.setView).toHaveBeenCalledWith({ center: [1, 51] })
|
|
204
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('does not call setView when Point feature has null coordinates', () => {
|
|
208
|
+
const nullCoordsFeature = { type: 'Feature', geometry: { type: 'Point', coordinates: null }, properties: {} }
|
|
209
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
210
|
+
panelConfig: { nullPanel: { visibleGeometry: nullCoordsFeature, desktop: { slot: 'inset' } } }
|
|
211
|
+
})
|
|
212
|
+
renderHook(() => useVisibleGeometry())
|
|
213
|
+
capturedHandlers['app:panelopened']({ panelId: 'nullPanel' })
|
|
214
|
+
jest.runAllTimers()
|
|
215
|
+
expect(mapProvider.setView).not.toHaveBeenCalled()
|
|
216
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('uses bottom slot ref when panel is in bottom slot', () => {
|
|
220
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
221
|
+
panelConfig: { bottomPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'bottom' } } }
|
|
222
|
+
})
|
|
223
|
+
renderHook(() => useVisibleGeometry())
|
|
224
|
+
capturedHandlers['app:panelopened']({ panelId: 'bottomPanel' })
|
|
225
|
+
jest.runAllTimers()
|
|
226
|
+
|
|
227
|
+
expect(mapProvider.isGeometryObscured).toHaveBeenCalledWith(polygonFeature, bottomPanelRect)
|
|
228
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('uses latest panelConfig via ref when it changes between renders', () => {
|
|
232
|
+
const { mapProvider, capturedHandlers, insetEl } = setup()
|
|
233
|
+
const { rerender } = renderHook(() => useVisibleGeometry())
|
|
234
|
+
|
|
235
|
+
const updatedGeometry = { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} }
|
|
236
|
+
const updatedPanelConfig = { myPanel: { visibleGeometry: updatedGeometry, desktop: { slot: 'inset' } } }
|
|
237
|
+
useApp.mockReturnValue({
|
|
238
|
+
layoutRefs: { mainRef: { current: document.createElement('div') }, insetRef: { current: insetEl } },
|
|
239
|
+
panelConfig: updatedPanelConfig,
|
|
240
|
+
panelRegistry: { getPanelConfig: jest.fn(() => updatedPanelConfig) },
|
|
241
|
+
breakpoint: 'desktop'
|
|
242
|
+
})
|
|
243
|
+
rerender()
|
|
244
|
+
|
|
245
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
246
|
+
jest.runAllTimers()
|
|
247
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(updatedGeometry)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('uses slot from event payload when registry config lacks slot info', () => {
|
|
251
|
+
const freshGeometry = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} }
|
|
252
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
253
|
+
panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry } })) }
|
|
254
|
+
})
|
|
255
|
+
renderHook(() => useVisibleGeometry())
|
|
256
|
+
// Event includes slot (as middleware provides for ADD_PANEL); registry config has no slot info
|
|
257
|
+
capturedHandlers['app:panelopened']({ panelId: 'freshPanel', slot: 'inset' })
|
|
258
|
+
jest.runAllTimers()
|
|
259
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(freshGeometry)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('falls back to panelRegistry for panels not yet in stale panelConfig', () => {
|
|
263
|
+
const freshGeometry = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} }
|
|
264
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
265
|
+
panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry, desktop: { slot: 'inset' } } })) }
|
|
266
|
+
})
|
|
267
|
+
renderHook(() => useVisibleGeometry())
|
|
268
|
+
capturedHandlers['app:panelopened']({ panelId: 'freshPanel' })
|
|
269
|
+
jest.runAllTimers()
|
|
270
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(freshGeometry)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('falls back to config when panel not in panelConfig and registry returns null', () => {
|
|
274
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
275
|
+
panelConfig: {}, // panel not present
|
|
276
|
+
panelRegistry: { getPanelConfig: jest.fn(() => null) } // registry returns null
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
renderHook(() => useVisibleGeometry())
|
|
280
|
+
capturedHandlers['app:panelopened']({ panelId: 'missingPanel', visibleGeometry: polygonFeature, slot: 'inset' })
|
|
281
|
+
jest.runAllTimers()
|
|
282
|
+
// Should still call fitToBounds using visibleGeometry from event payload
|
|
283
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('uses visibleGeometry from event payload directly, bypassing registry (ADD_PANEL first-click case)', () => {
|
|
287
|
+
// Registry is empty — simulates first ADD_PANEL before React has processed the reducer
|
|
288
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
289
|
+
panelRegistry: { getPanelConfig: jest.fn(() => ({})) }
|
|
290
|
+
})
|
|
291
|
+
renderHook(() => useVisibleGeometry())
|
|
292
|
+
capturedHandlers['app:panelopened']({ panelId: 'newPanel', slot: 'inset', visibleGeometry: polygonFeature })
|
|
293
|
+
jest.runAllTimers()
|
|
294
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
describe('getGeometryType', () => {
|
|
299
|
+
test('returns null for falsy input', () => {
|
|
300
|
+
expect(getGeometryType(null)).toBeNull()
|
|
301
|
+
expect(getGeometryType(undefined)).toBeNull()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('returns geometry type for a Feature', () => {
|
|
305
|
+
expect(getGeometryType({ type: 'Feature', geometry: { type: 'Polygon' } })).toBe('Polygon')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('returns type directly for a raw geometry', () => {
|
|
309
|
+
expect(getGeometryType({ type: 'Point' })).toBe('Point')
|
|
310
|
+
expect(getGeometryType({ type: 'FeatureCollection' })).toBe('FeatureCollection')
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('getPointCoordinates', () => {
|
|
315
|
+
test('returns null for unrecognised geometry type', () => {
|
|
316
|
+
expect(getPointCoordinates({ type: 'Polygon', coordinates: [] })).toBeNull()
|
|
317
|
+
expect(getPointCoordinates({ type: 'LineString', coordinates: [] })).toBeNull()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('returns coordinates for a Point', () => {
|
|
321
|
+
expect(getPointCoordinates(pointFeature.geometry)).toEqual(pointFeature.geometry.coordinates)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('returns first coordinate for a MultiPoint', () => {
|
|
325
|
+
expect(getPointCoordinates(multiPointFeature.geometry)).toEqual(multiPointFeature.geometry.coordinates[0])
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('recurses into Feature geometry', () => {
|
|
329
|
+
expect(getPointCoordinates(pointFeature)).toEqual(pointFeature.geometry.coordinates)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
@@ -70,7 +70,7 @@ export const Layout = () => {
|
|
|
70
70
|
<div className='im-o-app__right-top'>
|
|
71
71
|
<SlotRenderer slot={layoutSlots.RIGHT_TOP} />
|
|
72
72
|
</div>
|
|
73
|
-
<div className='im-o-app__right-bottom'>
|
|
73
|
+
<div className='im-o-app__right-bottom' ref={layoutRefs.rightBottomRef}>
|
|
74
74
|
<SlotRenderer slot={layoutSlots.RIGHT_BOTTOM} />
|
|
75
75
|
</div>
|
|
76
76
|
</div>
|
|
@@ -322,10 +322,7 @@
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
[class*="im-c-panel--"][class*="-button"] { // Adjacent to button
|
|
325
|
-
|
|
326
|
-
right: var(--modal-inset);
|
|
327
|
-
bottom: var(--modal-inset);
|
|
328
|
-
left: var(--modal-inset);
|
|
325
|
+
inset: var(--modal-inset);
|
|
329
326
|
}
|
|
330
327
|
}
|
|
331
328
|
|
|
@@ -18,6 +18,7 @@ export const getSlotRef = (slot, layoutRefs) => {
|
|
|
18
18
|
inset: layoutRefs.insetRef,
|
|
19
19
|
middle: layoutRefs.middleRef,
|
|
20
20
|
bottom: layoutRefs.bottomRef,
|
|
21
|
+
'right-bottom': layoutRefs.rightBottomRef,
|
|
21
22
|
actions: layoutRefs.actionsRef,
|
|
22
23
|
modal: layoutRefs.modalRef
|
|
23
24
|
}
|