@defra/interactive-map 0.0.18-alpha → 0.0.19-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/assets/css/docusaurus.css +58 -34
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/panel-definition.md +16 -0
- package/docs/api.md +28 -4
- package/docs/assets/basic-map.jpg +0 -0
- package/docs/assets/button-first.jpg +0 -0
- package/docs/assets/maker-panel.jpg +0 -0
- package/docs/examples/add-marker-with-panel.mdx +59 -0
- package/docs/examples/basic-map.mdx +24 -0
- package/docs/examples/button-map.mdx +24 -0
- package/docs/examples/index.mdx +49 -0
- package/docs/index.mdx +1 -1
- package/docs/plugins/interact.md +32 -1
- package/docs/plugins.md +1 -1
- package/docusaurus.config.cjs +9 -1
- package/package.json +1 -1
- 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/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/css/index.css +2 -19
- 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/scale-bar/dist/css/index.css +1 -1
- package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
- 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/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +5 -3
- package/plugins/interact/src/api/clear.js +1 -1
- package/plugins/interact/src/api/selectMarker.js +14 -0
- package/plugins/interact/src/api/selectMarker.test.js +25 -0
- package/plugins/interact/src/api/unselectMarker.js +14 -0
- package/plugins/interact/src/api/unselectMarker.test.js +14 -0
- package/plugins/interact/src/events.js +18 -30
- package/plugins/interact/src/events.test.js +113 -108
- package/plugins/interact/src/manifest.js +10 -2
- package/plugins/interact/src/reducer.js +36 -1
- package/plugins/interact/src/reducer.test.js +40 -1
- package/plugins/interact/src/utils/interactionModes.js +12 -0
- package/src/App/components/Panel/Panel.jsx +6 -6
- package/src/App/components/Panel/Panel.test.jsx +37 -0
- package/src/App/components/Viewport/Viewport.jsx +5 -15
- package/src/App/components/Viewport/Viewport.module.scss +2 -0
- package/src/App/components/Viewport/Viewport.test.jsx +16 -33
- package/src/App/hooks/useInterfaceAPI.js +7 -7
- package/src/App/hooks/useInterfaceAPI.test.js +15 -9
- package/src/App/hooks/useLayoutMeasurements.js +64 -72
- package/src/App/layout/Layout.jsx +1 -1
- package/src/App/layout/layout.module.scss +1 -8
- package/src/App/renderer/HtmlElementHost.jsx +10 -5
- package/src/App/renderer/mapPanels.js +2 -1
- package/src/App/store/appActionsMap.js +4 -4
- package/src/App/store/appActionsMap.test.js +10 -0
- package/src/InteractiveMap/InteractiveMap.js +59 -11
- package/src/InteractiveMap/InteractiveMap.test.js +126 -4
- package/src/InteractiveMap/domStateManager.js +18 -6
- package/src/InteractiveMap/domStateManager.test.js +21 -0
- package/src/InteractiveMap/historyManager.js +28 -16
- package/src/InteractiveMap/historyManager.test.js +17 -0
- package/src/config/appConfig.js +2 -1
- package/src/config/appConfig.test.js +3 -13
- package/src/config/defaults.js +2 -1
- package/src/config/events.js +20 -21
- package/src/services/closeApp.js +1 -10
- package/src/services/closeApp.test.js +3 -43
- package/src/types.js +6 -1
- package/src/utils/mapStateSync.js +48 -10
- package/src/utils/mapStateSync.test.js +29 -9
- package/docs/examples.mdx +0 -70
|
@@ -1,40 +1,38 @@
|
|
|
1
1
|
import { attachEvents } from './events.js'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
it('keyboard Enter triggers only on viewport', () => {
|
|
3
|
+
const MOCK_POINT = { x: 1, y: 2 }
|
|
4
|
+
const MOCK_COORDS = [1, 2]
|
|
5
|
+
const INTERACT_DONE = 'interact:done'
|
|
6
|
+
|
|
7
|
+
const createParams = () => {
|
|
8
|
+
const appState = { layoutRefs: { viewportRef: { current: document.body } }, disabledButtons: new Set() }
|
|
9
|
+
const pluginState = { dispatch: jest.fn(), selectionBounds: null, selectedFeatures: [], selectedMarkers: [], closeOnAction: true, multiSelect: false }
|
|
10
|
+
const clickReadyRef = { current: false }
|
|
11
|
+
return {
|
|
12
|
+
appState,
|
|
13
|
+
pluginState,
|
|
14
|
+
clickReadyRef,
|
|
15
|
+
getAppState: () => appState,
|
|
16
|
+
getPluginState: () => pluginState,
|
|
17
|
+
mapState: {
|
|
18
|
+
markers: { remove: jest.fn(), getMarker: jest.fn(() => null) },
|
|
19
|
+
crossHair: { getDetail: jest.fn(() => ({ point: { x: 0, y: 0 }, coords: [0, 0] })) }
|
|
20
|
+
},
|
|
21
|
+
buttonConfig: { selectDone: {}, selectAtTarget: {}, selectCancel: {} },
|
|
22
|
+
events: { MAP_CLICK: 'map:click' },
|
|
23
|
+
eventBus: { on: jest.fn(), off: jest.fn(), emit: jest.fn() },
|
|
24
|
+
handleInteraction: jest.fn(),
|
|
25
|
+
closeApp: jest.fn()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('attachEvents — keyboard', () => {
|
|
30
|
+
let cleanup = null
|
|
31
|
+
|
|
32
|
+
beforeEach(() => { jest.useFakeTimers() })
|
|
33
|
+
afterEach(() => { cleanup?.(); jest.useRealTimers() })
|
|
34
|
+
|
|
35
|
+
it('Enter on viewport triggers interaction', () => {
|
|
38
36
|
const params = createParams()
|
|
39
37
|
cleanup = attachEvents(params)
|
|
40
38
|
|
|
@@ -54,7 +52,6 @@ describe('attachEvents', () => {
|
|
|
54
52
|
cleanup = attachEvents(params)
|
|
55
53
|
const input = document.createElement('input')
|
|
56
54
|
|
|
57
|
-
// Enter outside viewport
|
|
58
55
|
let kd = new KeyboardEvent('keydown', { key: 'Enter' })
|
|
59
56
|
Object.defineProperty(kd, 'target', { value: input })
|
|
60
57
|
document.dispatchEvent(kd)
|
|
@@ -62,7 +59,6 @@ describe('attachEvents', () => {
|
|
|
62
59
|
Object.defineProperty(ku, 'target', { value: input })
|
|
63
60
|
document.dispatchEvent(ku)
|
|
64
61
|
|
|
65
|
-
// other key
|
|
66
62
|
kd = new KeyboardEvent('keydown', { key: 'Space' })
|
|
67
63
|
Object.defineProperty(kd, 'target', { value: document.body })
|
|
68
64
|
document.dispatchEvent(kd)
|
|
@@ -72,6 +68,13 @@ describe('attachEvents', () => {
|
|
|
72
68
|
|
|
73
69
|
expect(params.handleInteraction).not.toHaveBeenCalled()
|
|
74
70
|
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('attachEvents — click handling', () => {
|
|
74
|
+
let cleanup = null
|
|
75
|
+
|
|
76
|
+
beforeEach(() => { jest.useFakeTimers() })
|
|
77
|
+
afterEach(() => { cleanup?.(); jest.useRealTimers() })
|
|
75
78
|
|
|
76
79
|
it('map click triggers interaction when clickReadyRef is true', () => {
|
|
77
80
|
const params = createParams()
|
|
@@ -79,18 +82,17 @@ describe('attachEvents', () => {
|
|
|
79
82
|
cleanup = attachEvents(params)
|
|
80
83
|
|
|
81
84
|
const handler = params.eventBus.on.mock.calls.find(c => c[0] === 'map:click')[1]
|
|
82
|
-
handler({ point:
|
|
85
|
+
handler({ point: MOCK_POINT, coords: MOCK_COORDS })
|
|
83
86
|
|
|
84
|
-
expect(params.handleInteraction).toHaveBeenCalledWith({ point:
|
|
87
|
+
expect(params.handleInteraction).toHaveBeenCalledWith({ point: MOCK_POINT, coords: MOCK_COORDS })
|
|
85
88
|
})
|
|
86
89
|
|
|
87
90
|
it('map click is suppressed when clickReadyRef is false', () => {
|
|
88
91
|
const params = createParams()
|
|
89
|
-
params.clickReadyRef.current = false
|
|
90
92
|
cleanup = attachEvents(params)
|
|
91
93
|
|
|
92
94
|
const handler = params.eventBus.on.mock.calls.find(c => c[0] === 'map:click')[1]
|
|
93
|
-
handler({ point:
|
|
95
|
+
handler({ point: MOCK_POINT, coords: MOCK_COORDS })
|
|
94
96
|
|
|
95
97
|
expect(params.handleInteraction).not.toHaveBeenCalled()
|
|
96
98
|
})
|
|
@@ -99,135 +101,138 @@ describe('attachEvents', () => {
|
|
|
99
101
|
const params = createParams()
|
|
100
102
|
cleanup = attachEvents(params)
|
|
101
103
|
|
|
102
|
-
const crossDetail = { point:
|
|
104
|
+
const crossDetail = { point: MOCK_POINT, coords: MOCK_COORDS }
|
|
103
105
|
params.mapState.crossHair.getDetail.mockReturnValue(crossDetail)
|
|
104
106
|
|
|
105
107
|
params.buttonConfig.selectAtTarget.onClick()
|
|
106
108
|
expect(params.handleInteraction).toHaveBeenCalledWith(crossDetail)
|
|
107
109
|
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('attachEvents — button actions', () => {
|
|
113
|
+
let cleanup = null
|
|
114
|
+
|
|
115
|
+
beforeEach(() => { jest.useFakeTimers() })
|
|
116
|
+
afterEach(() => { cleanup?.(); jest.useRealTimers() })
|
|
108
117
|
|
|
109
118
|
it('selectDone emits correct payload and respects closeOnAction', () => {
|
|
110
119
|
const params = createParams()
|
|
111
120
|
cleanup = attachEvents(params)
|
|
112
121
|
|
|
113
|
-
|
|
114
|
-
params.mapState.markers.getMarker.mockReturnValue({ coords: [1, 2] })
|
|
122
|
+
params.mapState.markers.getMarker.mockReturnValue({ coords: MOCK_COORDS })
|
|
115
123
|
params.buttonConfig.selectDone.onClick()
|
|
116
124
|
expect(params.closeApp).toHaveBeenCalled()
|
|
117
125
|
|
|
118
|
-
// cover closeOnAction = false
|
|
119
126
|
params.closeApp.mockClear()
|
|
120
127
|
params.pluginState.closeOnAction = false
|
|
121
|
-
params.mapState.markers.getMarker.mockReturnValue({ coords: [3, 4] })
|
|
122
128
|
params.buttonConfig.selectDone.onClick()
|
|
123
129
|
expect(params.closeApp).not.toHaveBeenCalled()
|
|
124
130
|
})
|
|
125
131
|
|
|
126
|
-
it('
|
|
132
|
+
it('selectDone emits selectedFeatures and selectionBounds when no marker', () => {
|
|
127
133
|
const params = createParams()
|
|
128
134
|
cleanup = attachEvents(params)
|
|
129
135
|
|
|
130
|
-
|
|
131
|
-
params.
|
|
132
|
-
|
|
136
|
+
params.pluginState.selectedFeatures = [{ id: 'f1' }]
|
|
137
|
+
params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] }
|
|
138
|
+
params.buttonConfig.selectDone.onClick()
|
|
133
139
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
params2.pluginState.closeOnAction = false
|
|
139
|
-
params2.buttonConfig.selectCancel.onClick()
|
|
140
|
-
expect(params2.closeApp).not.toHaveBeenCalled()
|
|
140
|
+
expect(params.eventBus.emit).toHaveBeenCalledWith(INTERACT_DONE, {
|
|
141
|
+
selectedFeatures: [{ id: 'f1' }],
|
|
142
|
+
selectionBounds: { sw: [0, 0], ne: [1, 1] }
|
|
143
|
+
})
|
|
141
144
|
})
|
|
142
145
|
|
|
143
|
-
it('
|
|
146
|
+
it('selectDone includes selectedMarkers in payload when present', () => {
|
|
144
147
|
const params = createParams()
|
|
145
148
|
cleanup = attachEvents(params)
|
|
146
149
|
|
|
147
|
-
params.
|
|
150
|
+
params.pluginState.selectedMarkers = ['m1', 'm2']
|
|
148
151
|
params.buttonConfig.selectDone.onClick()
|
|
149
152
|
|
|
150
|
-
expect(params.eventBus.emit).
|
|
151
|
-
|
|
153
|
+
expect(params.eventBus.emit).toHaveBeenCalledWith(INTERACT_DONE,
|
|
154
|
+
expect.objectContaining({ selectedMarkers: ['m1', 'm2'] })
|
|
155
|
+
)
|
|
152
156
|
})
|
|
153
157
|
|
|
154
|
-
it('
|
|
158
|
+
it('selectDone omits selectedMarkers from payload when empty', () => {
|
|
155
159
|
const params = createParams()
|
|
156
160
|
cleanup = attachEvents(params)
|
|
157
161
|
|
|
158
|
-
|
|
159
|
-
const unselectHandler = params.eventBus.on.mock.calls.find(c => c[0] === 'interact:unselectFeature')[1]
|
|
160
|
-
|
|
161
|
-
selectHandler({ featureId: 'F1' })
|
|
162
|
-
unselectHandler({ featureId: 'F2' })
|
|
162
|
+
params.buttonConfig.selectDone.onClick()
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
expect(
|
|
164
|
+
const payload = params.eventBus.emit.mock.calls.find(c => c[0] === INTERACT_DONE)[1]
|
|
165
|
+
expect(payload).not.toHaveProperty('selectedMarkers')
|
|
166
166
|
})
|
|
167
167
|
|
|
168
|
-
it('
|
|
168
|
+
it('does not emit or closeApp if selectDone button is disabled', () => {
|
|
169
169
|
const params = createParams()
|
|
170
170
|
cleanup = attachEvents(params)
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
|
|
172
|
+
params.appState.disabledButtons.add('selectDone')
|
|
173
|
+
params.buttonConfig.selectDone.onClick()
|
|
174
|
+
|
|
175
|
+
expect(params.eventBus.emit).not.toHaveBeenCalled()
|
|
176
|
+
expect(params.closeApp).not.toHaveBeenCalled()
|
|
173
177
|
})
|
|
174
178
|
|
|
175
|
-
it('
|
|
179
|
+
it('selectCancel emits cancel and respects closeOnAction', () => {
|
|
176
180
|
const params = createParams()
|
|
177
181
|
cleanup = attachEvents(params)
|
|
178
182
|
|
|
179
|
-
params.
|
|
180
|
-
params.
|
|
181
|
-
params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] }
|
|
182
|
-
|
|
183
|
-
params.buttonConfig.selectDone.onClick()
|
|
183
|
+
params.buttonConfig.selectCancel.onClick()
|
|
184
|
+
expect(params.closeApp).toHaveBeenCalled()
|
|
184
185
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
cleanup()
|
|
187
|
+
const params2 = createParams()
|
|
188
|
+
cleanup = attachEvents(params2)
|
|
189
|
+
params2.pluginState.closeOnAction = false
|
|
190
|
+
params2.buttonConfig.selectCancel.onClick()
|
|
191
|
+
expect(params2.closeApp).not.toHaveBeenCalled()
|
|
189
192
|
})
|
|
190
193
|
|
|
191
|
-
it('
|
|
194
|
+
it('respects default closeOnAction when value is nullish', () => {
|
|
192
195
|
const params = createParams()
|
|
196
|
+
params.pluginState.closeOnAction = null
|
|
193
197
|
cleanup = attachEvents(params)
|
|
194
198
|
|
|
195
|
-
params.mapState.markers.getMarker.mockReturnValue(null)
|
|
196
|
-
params.pluginState.selectedMarkers = ['m1', 'm2']
|
|
197
|
-
|
|
198
199
|
params.buttonConfig.selectDone.onClick()
|
|
200
|
+
expect(params.closeApp).toHaveBeenCalledTimes(1)
|
|
199
201
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
)
|
|
202
|
+
params.closeApp.mockClear()
|
|
203
|
+
params.buttonConfig.selectCancel.onClick()
|
|
204
|
+
expect(params.closeApp).toHaveBeenCalledTimes(1)
|
|
203
205
|
})
|
|
206
|
+
})
|
|
204
207
|
|
|
205
|
-
|
|
208
|
+
describe('attachEvents — programmatic selection', () => {
|
|
209
|
+
let cleanup = null
|
|
210
|
+
|
|
211
|
+
beforeEach(() => { jest.useFakeTimers() })
|
|
212
|
+
afterEach(() => { cleanup?.(); jest.useRealTimers() })
|
|
213
|
+
|
|
214
|
+
it('selectFeature and unselectFeature dispatch and remove location marker', () => {
|
|
206
215
|
const params = createParams()
|
|
207
216
|
cleanup = attachEvents(params)
|
|
208
217
|
|
|
209
|
-
params.
|
|
210
|
-
params.
|
|
218
|
+
const selectHandler = params.eventBus.on.mock.calls.find(c => c[0] === 'interact:selectFeature')[1]
|
|
219
|
+
const unselectHandler = params.eventBus.on.mock.calls.find(c => c[0] === 'interact:unselectFeature')[1]
|
|
211
220
|
|
|
212
|
-
|
|
221
|
+
selectHandler({ featureId: 'F1' })
|
|
222
|
+
unselectHandler({ featureId: 'F2' })
|
|
213
223
|
|
|
214
|
-
|
|
215
|
-
expect(
|
|
224
|
+
expect(params.pluginState.dispatch).toHaveBeenCalledTimes(2)
|
|
225
|
+
expect(params.mapState.markers.remove).toHaveBeenCalledTimes(2)
|
|
216
226
|
})
|
|
227
|
+
})
|
|
217
228
|
|
|
218
|
-
|
|
229
|
+
describe('attachEvents — cleanup', () => {
|
|
230
|
+
it('removes all handlers and nulls button onClick callbacks', () => {
|
|
231
|
+
jest.useFakeTimers()
|
|
219
232
|
const params = createParams()
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
// Test for selectDone
|
|
225
|
-
params.buttonConfig.selectDone.onClick()
|
|
226
|
-
expect(params.closeApp).toHaveBeenCalledTimes(1)
|
|
227
|
-
|
|
228
|
-
// Test for selectCancel
|
|
229
|
-
params.closeApp.mockClear()
|
|
230
|
-
params.buttonConfig.selectCancel.onClick()
|
|
231
|
-
expect(params.closeApp).toHaveBeenCalledTimes(1)
|
|
233
|
+
const cleanup = attachEvents(params)
|
|
234
|
+
cleanup()
|
|
235
|
+
Object.values(params.buttonConfig).forEach(btn => expect(btn.onClick).toBeNull())
|
|
236
|
+
jest.useRealTimers()
|
|
232
237
|
})
|
|
233
238
|
})
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// /plugins/interact/manifest.js
|
|
2
2
|
import { InteractInit } from './InteractInit.jsx'
|
|
3
|
+
import { isSelectMarkerOnly } from './utils/interactionModes.js'
|
|
3
4
|
import { initialState, actions } from './reducer.js'
|
|
4
5
|
import { enable } from './api/enable.js'
|
|
5
6
|
import { disable } from './api/disable.js'
|
|
6
7
|
import { clear } from './api/clear.js'
|
|
7
8
|
import { selectFeature } from './api/selectFeature.js'
|
|
8
9
|
import { unselectFeature } from './api/unselectFeature.js'
|
|
10
|
+
import { selectMarker } from './api/selectMarker.js'
|
|
11
|
+
import { unselectMarker } from './api/unselectMarker.js'
|
|
9
12
|
|
|
10
13
|
export const manifest = {
|
|
11
14
|
InitComponent: InteractInit,
|
|
@@ -36,7 +39,10 @@ export const manifest = {
|
|
|
36
39
|
id: 'selectAtTarget',
|
|
37
40
|
label: 'Select',
|
|
38
41
|
variant: 'primary',
|
|
39
|
-
|
|
42
|
+
// Hidden for touch when selectMarker is the only mode — markers have a sufficient tap target
|
|
43
|
+
// and the Select button is only needed alongside the crosshair. Mirrors the crosshair logic in InteractInit.
|
|
44
|
+
hiddenWhen: ({ appState, pluginState }) =>
|
|
45
|
+
!pluginState.enabled || appState.interfaceType !== 'touch' || isSelectMarkerOnly(pluginState.interactionModes),
|
|
40
46
|
mobile: {
|
|
41
47
|
slot: 'actions'
|
|
42
48
|
},
|
|
@@ -83,6 +89,8 @@ export const manifest = {
|
|
|
83
89
|
disable,
|
|
84
90
|
clear,
|
|
85
91
|
selectFeature,
|
|
86
|
-
unselectFeature
|
|
92
|
+
unselectFeature,
|
|
93
|
+
selectMarker,
|
|
94
|
+
unselectMarker
|
|
87
95
|
}
|
|
88
96
|
}
|
|
@@ -107,13 +107,48 @@ const clearSelectedFeatures = (state) => {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Explicitly select a marker. Has no effect if already selected.
|
|
112
|
+
* In single-select mode, clears selectedFeatures and replaces the selection.
|
|
113
|
+
* In multi-select mode, adds to the existing selection.
|
|
114
|
+
*/
|
|
115
|
+
const selectMarker = (state, { markerId, multiSelect }) => {
|
|
116
|
+
const current = state.selectedMarkers
|
|
117
|
+
if (current.includes(markerId)) {
|
|
118
|
+
return state
|
|
119
|
+
}
|
|
120
|
+
const nextMarkers = multiSelect ? [...current, markerId] : [markerId]
|
|
121
|
+
return {
|
|
122
|
+
...state,
|
|
123
|
+
selectedFeatures: multiSelect ? state.selectedFeatures : [],
|
|
124
|
+
selectionBounds: null,
|
|
125
|
+
selectedMarkers: nextMarkers
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Explicitly unselect a marker. Has no effect if not selected.
|
|
131
|
+
*/
|
|
132
|
+
const unselectMarker = (state, { markerId }) => {
|
|
133
|
+
const current = state.selectedMarkers
|
|
134
|
+
if (!current.includes(markerId)) {
|
|
135
|
+
return state
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
...state,
|
|
139
|
+
selectedMarkers: current.filter(id => id !== markerId)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
110
143
|
const actions = {
|
|
111
144
|
ENABLE: enable,
|
|
112
145
|
DISABLE: disable,
|
|
113
146
|
TOGGLE_SELECTED_FEATURES: toggleSelectedFeatures,
|
|
114
147
|
TOGGLE_SELECTED_MARKERS: toggleSelectedMarkers,
|
|
115
148
|
UPDATE_SELECTED_BOUNDS: updateSelectedBounds,
|
|
116
|
-
CLEAR_SELECTED_FEATURES: clearSelectedFeatures
|
|
149
|
+
CLEAR_SELECTED_FEATURES: clearSelectedFeatures,
|
|
150
|
+
SELECT_MARKER: selectMarker,
|
|
151
|
+
UNSELECT_MARKER: unselectMarker
|
|
117
152
|
}
|
|
118
153
|
|
|
119
154
|
export {
|
|
@@ -189,6 +189,43 @@ describe('CLEAR_SELECTED_FEATURES action', () => {
|
|
|
189
189
|
})
|
|
190
190
|
})
|
|
191
191
|
|
|
192
|
+
describe('SELECT_MARKER action', () => {
|
|
193
|
+
it('adds a marker in single-select mode, clearing selectedFeatures', () => {
|
|
194
|
+
const state = { ...initialState, selectedFeatures: [{ featureId: 'f1' }], selectedMarkers: [] }
|
|
195
|
+
const result = actions.SELECT_MARKER(state, { markerId: 'm1', multiSelect: false })
|
|
196
|
+
expect(result.selectedMarkers).toEqual(['m1'])
|
|
197
|
+
expect(result.selectedFeatures).toEqual([])
|
|
198
|
+
expect(result.selectionBounds).toBeNull()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('adds a marker in multi-select mode without clearing selectedFeatures', () => {
|
|
202
|
+
const state = { ...initialState, selectedFeatures: [{ featureId: 'f1' }], selectedMarkers: ['m1'] }
|
|
203
|
+
const result = actions.SELECT_MARKER(state, { markerId: 'm2', multiSelect: true })
|
|
204
|
+
expect(result.selectedMarkers).toEqual(['m1', 'm2'])
|
|
205
|
+
expect(result.selectedFeatures).toEqual([{ featureId: 'f1' }])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('is idempotent — does not change state if marker already selected', () => {
|
|
209
|
+
const state = { ...initialState, selectedMarkers: ['m1'] }
|
|
210
|
+
const result = actions.SELECT_MARKER(state, { markerId: 'm1', multiSelect: false })
|
|
211
|
+
expect(result).toBe(state)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('UNSELECT_MARKER action', () => {
|
|
216
|
+
it('removes a selected marker', () => {
|
|
217
|
+
const state = { ...initialState, selectedMarkers: ['m1', 'm2'] }
|
|
218
|
+
const result = actions.UNSELECT_MARKER(state, { markerId: 'm1' })
|
|
219
|
+
expect(result.selectedMarkers).toEqual(['m2'])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('is idempotent — does not change state if marker not selected', () => {
|
|
223
|
+
const state = { ...initialState, selectedMarkers: ['m2'] }
|
|
224
|
+
const result = actions.UNSELECT_MARKER(state, { markerId: 'm1' })
|
|
225
|
+
expect(result).toBe(state)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
192
229
|
describe('actions object', () => {
|
|
193
230
|
it('exports all action handlers as functions', () => {
|
|
194
231
|
expect(Object.keys(actions)).toEqual([
|
|
@@ -197,7 +234,9 @@ describe('actions object', () => {
|
|
|
197
234
|
'TOGGLE_SELECTED_FEATURES',
|
|
198
235
|
'TOGGLE_SELECTED_MARKERS',
|
|
199
236
|
'UPDATE_SELECTED_BOUNDS',
|
|
200
|
-
'CLEAR_SELECTED_FEATURES'
|
|
237
|
+
'CLEAR_SELECTED_FEATURES',
|
|
238
|
+
'SELECT_MARKER',
|
|
239
|
+
'UNSELECT_MARKER'
|
|
201
240
|
])
|
|
202
241
|
Object.values(actions).forEach(fn => expect(typeof fn).toBe('function'))
|
|
203
242
|
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when selectMarker is the only active interaction mode.
|
|
3
|
+
*
|
|
4
|
+
* Used to suppress the crosshair and Select action button for touch users —
|
|
5
|
+
* markers have a sufficient tap target so neither is needed. Any other mode
|
|
6
|
+
* combination (e.g. placeMarker, selectFeature) requires the crosshair for touch.
|
|
7
|
+
*
|
|
8
|
+
* @param {string[] | null} interactionModes
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export const isSelectMarkerOnly = (interactionModes) =>
|
|
12
|
+
interactionModes?.length === 1 && interactionModes.includes('selectMarker')
|
|
@@ -6,12 +6,12 @@ import { useModalPanelBehaviour } from '../../hooks/useModalPanelBehaviour.js'
|
|
|
6
6
|
import { useIsScrollable } from '../../hooks/useIsScrollable.js'
|
|
7
7
|
import { Icon } from '../Icon/Icon'
|
|
8
8
|
|
|
9
|
-
const computePanelState = (bpConfig, triggeringElement) => {
|
|
9
|
+
const computePanelState = (bpConfig, triggeringElement, focus, focusOnOpen) => {
|
|
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
13
|
const isDismissible = bpConfig.dismissible !== false
|
|
14
|
-
const shouldFocus =
|
|
14
|
+
const shouldFocus = isModal || (focusOnOpen !== false && (focusOnOpen === true || focus === true || Boolean(triggeringElement)))
|
|
15
15
|
const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
|
|
16
16
|
return { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl }
|
|
17
17
|
}
|
|
@@ -58,15 +58,15 @@ const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }
|
|
|
58
58
|
|
|
59
59
|
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
60
60
|
// sonarjs/disable-next-line function-name
|
|
61
|
-
export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html, children, isOpen = true, rootRef }) => {
|
|
61
|
+
export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild, label, html, children, isOpen = true, rootRef }) => {
|
|
62
62
|
const { id } = useConfig()
|
|
63
|
-
const { dispatch, breakpoint, layoutRefs } = useApp()
|
|
63
|
+
const { dispatch, breakpoint, layoutRefs, interfaceType } = useApp()
|
|
64
64
|
|
|
65
65
|
const rootEl = document.getElementById(`${id}-im-app`)
|
|
66
66
|
const bpConfig = panelConfig[breakpoint]
|
|
67
67
|
const elementId = `${id}-panel-${stringToKebab(panelId)}`
|
|
68
68
|
|
|
69
|
-
const { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement)
|
|
69
|
+
const { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement, panelConfig.focus, focusOnOpen) // nosonar
|
|
70
70
|
|
|
71
71
|
// For persistent panels, gate modal behaviour on open state
|
|
72
72
|
const isModalActive = isModal && isOpen
|
|
@@ -81,7 +81,7 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
|
|
|
81
81
|
const panelRef = rootRef || internalPanelRef
|
|
82
82
|
|
|
83
83
|
const handleClose = () => {
|
|
84
|
-
requestAnimationFrame(() => { (props?.triggeringElement || layoutRefs.viewportRef.current).focus?.() })
|
|
84
|
+
requestAnimationFrame(() => { (props?.triggeringElement || layoutRefs.viewportRef.current).focus?.({ preventScroll: interfaceType !== 'keyboard' }) })
|
|
85
85
|
dispatch({ type: 'CLOSE_PANEL', payload: panelId })
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -104,6 +104,43 @@ describe('Panel', () => {
|
|
|
104
104
|
})
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
+
describe('focus behaviour', () => {
|
|
108
|
+
it('focuses panel on mount when focus: true', () => {
|
|
109
|
+
const { container } = renderPanel({ focus: true })
|
|
110
|
+
const panel = container.firstChild
|
|
111
|
+
expect(panel).toHaveAttribute('tabIndex', '-1')
|
|
112
|
+
expect(document.activeElement).toBe(panel)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('does not focus panel on mount when focus: false and no triggering element or modal', () => {
|
|
116
|
+
const { container } = renderPanel({ focus: false })
|
|
117
|
+
const panel = container.firstChild
|
|
118
|
+
expect(panel).not.toHaveAttribute('tabIndex')
|
|
119
|
+
expect(document.activeElement).not.toBe(panel)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('focuses panel on mount when modal even if focus: false', () => {
|
|
123
|
+
const { container } = renderPanel({ focus: false, desktop: { slot: 'overlay', dismissible: true, modal: true } })
|
|
124
|
+
const panel = container.firstChild
|
|
125
|
+
expect(panel).toHaveAttribute('tabIndex', '-1')
|
|
126
|
+
expect(document.activeElement).toBe(panel)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('focuses panel when focusOnOpen is true regardless of panelConfig.focus', () => {
|
|
130
|
+
const { container } = renderPanel({ focus: false }, { focusOnOpen: true })
|
|
131
|
+
const panel = container.firstChild
|
|
132
|
+
expect(panel).toHaveAttribute('tabIndex', '-1')
|
|
133
|
+
expect(document.activeElement).toBe(panel)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('does not focus panel when focusOnOpen is false even if panelConfig.focus is true', () => {
|
|
137
|
+
const { container } = renderPanel({ focus: true }, { focusOnOpen: false })
|
|
138
|
+
const panel = container.firstChild
|
|
139
|
+
expect(panel).not.toHaveAttribute('tabIndex')
|
|
140
|
+
expect(document.activeElement).not.toBe(panel)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
107
144
|
describe('close functionality', () => {
|
|
108
145
|
it('focuses triggeringElement on close for button slots', () => {
|
|
109
146
|
const focusMock = jest.fn()
|
|
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState } from 'react'
|
|
|
2
2
|
import { EVENTS as events } from '../../../config/events.js'
|
|
3
3
|
import { createPortal } from 'react-dom'
|
|
4
4
|
import { useConfig } from '../../store/configContext.js'
|
|
5
|
+
|
|
5
6
|
import { useApp } from '../../store/appContext.js'
|
|
6
7
|
import { useMap } from '../../store/mapContext.js'
|
|
7
8
|
import { MapController } from './MapController.jsx'
|
|
@@ -14,9 +15,10 @@ import { Markers } from '../Markers/Markers'
|
|
|
14
15
|
|
|
15
16
|
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
16
17
|
// sonarjs/disable-next-line function-name
|
|
17
|
-
export const Viewport = (
|
|
18
|
+
export const Viewport = () => {
|
|
18
19
|
const { id, mapProvider, mapLabel, keyboardHintText } = useConfig()
|
|
19
20
|
const { interfaceType, mode, previousMode, layoutRefs, safeZoneInset } = useApp()
|
|
21
|
+
const { mainRef } = layoutRefs
|
|
20
22
|
const { mapSize } = useMap()
|
|
21
23
|
|
|
22
24
|
const mapContainerRef = useRef(null)
|
|
@@ -49,18 +51,6 @@ export const Viewport = ({ keyboardHintPortalRef }) => {
|
|
|
49
51
|
}
|
|
50
52
|
}, [mode])
|
|
51
53
|
|
|
52
|
-
// Toggle external class based on keyboard hint
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
const mainEl = layoutRefs.mainRef?.current
|
|
55
|
-
if (!mainEl) {
|
|
56
|
-
return undefined
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
mainEl.classList.toggle('im-o-app__main--keyboard-hint-visible', showHint)
|
|
60
|
-
|
|
61
|
-
return () => mainEl?.classList.remove('im-o-app__main--keyboard-hint-visible')
|
|
62
|
-
}, [showHint])
|
|
63
|
-
|
|
64
54
|
return (
|
|
65
55
|
<>
|
|
66
56
|
<MapController mapContainerRef={mapContainerRef} />
|
|
@@ -74,14 +64,14 @@ export const Viewport = ({ keyboardHintPortalRef }) => {
|
|
|
74
64
|
onBlur={handleBlur}
|
|
75
65
|
ref={layoutRefs.viewportRef}
|
|
76
66
|
>
|
|
77
|
-
{showHint &&
|
|
67
|
+
{showHint && mainRef?.current && createPortal(
|
|
78
68
|
<div
|
|
79
69
|
className='im-c-viewport__keyboard-hint'
|
|
80
70
|
aria-hidden='true'
|
|
81
71
|
ref={keyboardHintRef}
|
|
82
72
|
dangerouslySetInnerHTML={{ __html: keyboardHintText }}
|
|
83
73
|
/>,
|
|
84
|
-
|
|
74
|
+
mainRef.current
|
|
85
75
|
)}
|
|
86
76
|
<div className='im-c-viewport__map-container' ref={mapContainerRef} />
|
|
87
77
|
<div className='im-c-viewport__features' />
|