@defra/interactive-map 0.0.11-alpha → 0.0.14-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/plugins/plugin-descriptor.md +37 -0
  6. package/package.json +15 -6
  7. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  8. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/src/events.js +4 -14
  10. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +1 -3
  11. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  12. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  13. package/plugins/interact/src/InteractInit.jsx +28 -6
  14. package/plugins/interact/src/InteractInit.test.js +19 -5
  15. package/plugins/interact/src/events.js +17 -15
  16. package/plugins/interact/src/events.test.js +25 -16
  17. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  18. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  19. package/plugins/search/src/events/fetchSuggestions.js +9 -6
  20. package/providers/beta/esri/dist/css/index.css +4 -0
  21. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  22. package/providers/beta/esri/src/esriProvider.js +19 -3
  23. package/providers/beta/esri/src/esriProvider.scss +5 -0
  24. package/providers/beta/esri/src/mapEvents.js +34 -3
  25. package/providers/beta/esri/src/utils/coords.js +1 -0
  26. package/providers/beta/esri/src/utils/spatial.js +47 -1
  27. package/providers/beta/esri/src/utils/spatial.test.js +55 -0
  28. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  29. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  30. package/providers/maplibre/src/maplibreProvider.js +12 -1
  31. package/providers/maplibre/src/maplibreProvider.test.js +14 -1
  32. package/providers/maplibre/src/utils/spatial.js +40 -0
  33. package/providers/maplibre/src/utils/spatial.test.js +35 -0
  34. package/src/App/components/MapButton/MapButton.jsx +1 -0
  35. package/src/App/components/Panel/Panel.jsx +14 -13
  36. package/src/App/components/Viewport/MapController.jsx +4 -0
  37. package/src/App/hooks/useLayoutMeasurements.js +37 -20
  38. package/src/App/hooks/useLayoutMeasurements.test.js +38 -6
  39. package/src/App/hooks/useMarkersAPI.js +5 -3
  40. package/src/App/hooks/useModalPanelBehaviour.js +91 -10
  41. package/src/App/hooks/useModalPanelBehaviour.test.js +185 -53
  42. package/src/App/hooks/useVisibleGeometry.js +100 -0
  43. package/src/App/hooks/useVisibleGeometry.test.js +331 -0
  44. package/src/App/layout/Layout.jsx +13 -5
  45. package/src/App/layout/layout.module.scss +149 -13
  46. package/src/App/renderer/HtmlElementHost.jsx +10 -2
  47. package/src/App/renderer/HtmlElementHost.test.jsx +12 -0
  48. package/src/App/renderer/SlotRenderer.jsx +1 -1
  49. package/src/App/renderer/mapPanels.js +1 -2
  50. package/src/App/renderer/pluginWrapper.js +3 -2
  51. package/src/App/renderer/slots.js +12 -6
  52. package/src/App/store/AppProvider.jsx +6 -1
  53. package/src/App/store/appDispatchMiddleware.js +19 -0
  54. package/src/App/store/appDispatchMiddleware.test.js +56 -0
  55. package/src/InteractiveMap/InteractiveMap.js +3 -3
  56. package/src/types.js +9 -0
  57. package/src/utils/getSafeZoneInset.js +12 -9
  58. package/src/utils/getSafeZoneInset.test.js +102 -58
@@ -4,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,28 +24,40 @@ describe('useModalPanelBehaviour', () => {
17
24
  main: { current: document.createElement('div') },
18
25
  panel: { current: document.createElement('div') }
19
26
  }
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'
30
+
20
31
  elements = {
21
32
  buttonContainer: document.createElement('div'),
22
33
  root: document.createElement('div')
23
34
  }
35
+
24
36
  elements.root.appendChild(refs.panel.current)
25
37
  document.body.appendChild(elements.root)
38
+
26
39
  handleClose = jest.fn()
27
40
  jest.clearAllMocks()
28
- document.documentElement.style.setProperty('--modal-inset', '')
41
+ document.documentElement.style.setProperty(MODAL_INSET, '')
42
+ document.documentElement.style.setProperty(MODAL_MAX_HEIGHT, '')
43
+ useApp.mockReturnValue({ layoutRefs: {} })
29
44
  })
30
45
 
31
46
  afterEach(() => {
32
47
  document.body.innerHTML = ''
33
48
  })
34
49
 
35
- const TestComponent = ({ isModal = true }) => {
50
+ const TestComponent = ({
51
+ isModal = true,
52
+ buttonContainerEl,
53
+ rootEl = elements.root
54
+ }) => {
36
55
  useModalPanelBehaviour({
37
56
  mainRef: refs.main,
38
57
  panelRef: refs.panel,
39
58
  isModal,
40
- rootEl: elements.root,
41
- buttonContainerEl: elements.buttonContainer,
59
+ rootEl,
60
+ buttonContainerEl,
42
61
  handleClose
43
62
  })
44
63
  return null
@@ -63,66 +82,139 @@ describe('useModalPanelBehaviour', () => {
63
82
  )
64
83
  })
65
84
 
66
- it('updates --modal-inset on resize', () => {
67
- useResizeObserverModule.useResizeObserver.mockImplementation((_, cb) => cb())
68
- Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
69
- value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 })
85
+ describe('positioning (--modal-inset, --modal-max-height)', () => {
86
+ const buttonSlot = 'map-styles-button'
87
+
88
+ beforeEach(() => {
89
+ // Force ResizeObserver to run the callback immediately
90
+ useResizeObserverModule.useResizeObserver.mockImplementation((_, cb) => cb())
91
+ jest.spyOn(globalThis, 'getComputedStyle').mockReturnValue({ getPropertyValue: () => '8' })
92
+
93
+ Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
94
+ value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 }),
95
+ configurable: true
96
+ })
97
+ Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
98
+ value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 }),
99
+ configurable: true
100
+ })
70
101
  })
71
- Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
72
- value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 })
102
+
103
+ afterEach(() => {
104
+ jest.restoreAllMocks()
73
105
  })
74
106
 
75
- render(<TestComponent />)
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
+ })
76
115
 
77
- const inset = getComputedStyle(document.documentElement).getPropertyValue('--modal-inset')
78
- expect(inset).toContain('10px')
79
- })
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
+ })
80
124
 
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
- }
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 } })
88
133
 
89
- it('calls handleClose when backdrop inside rootEl is clicked', () => {
90
- const backdrop = createBackdrop(elements.root)
91
134
  render(<TestComponent />)
92
- fireEvent.click(backdrop)
93
- expect(handleClose).toHaveBeenCalled()
135
+
136
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('60px auto auto 8px')
137
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
94
138
  })
95
139
 
96
- it('does not call handleClose when backdrop outside rootEl is clicked', () => {
97
- const backdrop = createBackdrop(document.body)
140
+ it('leaves --modal-inset unset when slot ref cannot be resolved', () => {
98
141
  render(<TestComponent />)
99
- fireEvent.click(backdrop)
100
- expect(handleClose).not.toHaveBeenCalled()
142
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('')
101
143
  })
102
144
 
103
- it('does not call handleClose when non-backdrop element is clicked', () => {
104
- elements.root.appendChild(document.createElement('div'))
105
- render(<TestComponent />)
106
- fireEvent.click(elements.root.firstChild)
107
- expect(handleClose).not.toHaveBeenCalled()
145
+ it('updates --modal-inset and --modal-max-height via aria-controls when buttonContainerEl is stale', () => {
146
+ refs.panel.current.dataset.slot = buttonSlot
147
+
148
+ const button = document.createElement('button')
149
+ button.setAttribute(ARIA_CONTROLS, PANEL_ID)
150
+ elements.buttonContainer.appendChild(button)
151
+ document.body.appendChild(elements.buttonContainer)
152
+
153
+ const staleEl = document.createElement('div') // detached
154
+ render(<TestComponent buttonContainerEl={staleEl} />)
155
+
156
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
157
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
108
158
  })
109
- })
110
159
 
111
- it('toggles inert elements on mount and cleanup', () => {
112
- const { unmount } = render(<TestComponent />)
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} />)
113
166
 
114
- expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith({
115
- containerEl: refs.panel.current,
116
- isFullscreen: true,
117
- boundaryEl: elements.root
167
+ expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
168
+ expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
118
169
  })
119
170
 
120
- unmount()
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')
179
+ })
121
180
 
122
- expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith({
123
- containerEl: refs.panel.current,
124
- isFullscreen: false,
125
- boundaryEl: elements.root
181
+ it('skips update when effectiveContainer cannot be resolved', () => {
182
+ refs.panel.current.dataset.slot = buttonSlot
183
+ render(<TestComponent buttonContainerEl={null} />)
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')
126
218
  })
127
219
  })
128
220
 
@@ -138,6 +230,20 @@ describe('useModalPanelBehaviour', () => {
138
230
  expect(refs.panel.current.focus).toHaveBeenCalled()
139
231
  })
140
232
 
233
+ // COVERS LINE 44 (The early return branch)
234
+ it('does not redirect focus when focus moves completely outside the app root', () => {
235
+ refs.panel.current.focus = jest.fn()
236
+ render(<TestComponent />)
237
+
238
+ const externalEl = document.createElement('button')
239
+ document.body.appendChild(externalEl) // Outside elements.root
240
+
241
+ dispatchFocusIn(externalEl)
242
+
243
+ // Since isInsideApp is false, it should hit the "return" and not call focus()
244
+ expect(refs.panel.current.focus).not.toHaveBeenCalled()
245
+ })
246
+
141
247
  it('does not redirect focus when focus is already inside panel', () => {
142
248
  refs.panel.current.focus = jest.fn()
143
249
  render(<TestComponent />)
@@ -149,23 +255,49 @@ describe('useModalPanelBehaviour', () => {
149
255
  expect(refs.panel.current.focus).not.toHaveBeenCalled()
150
256
  })
151
257
 
152
- it('handles edge cases gracefully', () => {
258
+ it('handles null focus targets gracefully', () => {
153
259
  render(<TestComponent />)
154
-
155
260
  dispatchFocusIn(null)
261
+ expect(true).toBe(true)
262
+ })
263
+ })
264
+
265
+ describe('backdrop and inert', () => {
266
+ it('calls handleClose when backdrop inside rootEl is clicked', () => {
267
+ const backdrop = document.createElement('div')
268
+ backdrop.className = 'im-o-app__modal-backdrop'
269
+ elements.root.appendChild(backdrop)
270
+
271
+ render(<TestComponent />)
272
+ fireEvent.click(backdrop)
273
+ expect(handleClose).toHaveBeenCalled()
274
+ })
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)
156
280
 
157
- refs.panel.current = null
158
- dispatchFocusIn(document.body)
281
+ render(<TestComponent />)
282
+ fireEvent.click(externalBackdrop)
283
+ expect(handleClose).not.toHaveBeenCalled()
284
+ })
159
285
 
160
- expect(true).toBe(true) // No errors thrown
286
+ it('toggles inert elements on mount and cleanup', () => {
287
+ const { unmount } = render(<TestComponent />)
288
+ expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith(
289
+ expect.objectContaining({ isFullscreen: true })
290
+ )
291
+ unmount()
292
+ expect(toggleInertModule.toggleInertElements).toHaveBeenCalledWith(
293
+ expect.objectContaining({ isFullscreen: false })
294
+ )
161
295
  })
162
296
  })
163
297
 
164
298
  it('does nothing when isModal is false', () => {
165
299
  render(<TestComponent isModal={false} />)
166
-
167
300
  fireEvent.keyDown(refs.panel.current, { key: 'Escape' })
168
301
  expect(handleClose).not.toHaveBeenCalled()
169
- expect(toggleInertModule.toggleInertElements).not.toHaveBeenCalled()
170
302
  })
171
303
  })
@@ -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
+ }