@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.
Files changed (73) hide show
  1. package/assets/css/docusaurus.css +58 -34
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/panel-definition.md +16 -0
  8. package/docs/api.md +28 -4
  9. package/docs/assets/basic-map.jpg +0 -0
  10. package/docs/assets/button-first.jpg +0 -0
  11. package/docs/assets/maker-panel.jpg +0 -0
  12. package/docs/examples/add-marker-with-panel.mdx +59 -0
  13. package/docs/examples/basic-map.mdx +24 -0
  14. package/docs/examples/button-map.mdx +24 -0
  15. package/docs/examples/index.mdx +49 -0
  16. package/docs/index.mdx +1 -1
  17. package/docs/plugins/interact.md +32 -1
  18. package/docs/plugins.md +1 -1
  19. package/docusaurus.config.cjs +9 -1
  20. package/package.json +1 -1
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  24. package/plugins/beta/draw-ml/dist/css/index.css +2 -19
  25. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  26. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  27. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  28. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  29. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  30. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  31. package/plugins/interact/dist/umd/index.js +1 -1
  32. package/plugins/interact/src/InteractInit.jsx +5 -3
  33. package/plugins/interact/src/api/clear.js +1 -1
  34. package/plugins/interact/src/api/selectMarker.js +14 -0
  35. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  36. package/plugins/interact/src/api/unselectMarker.js +14 -0
  37. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  38. package/plugins/interact/src/events.js +18 -30
  39. package/plugins/interact/src/events.test.js +113 -108
  40. package/plugins/interact/src/manifest.js +10 -2
  41. package/plugins/interact/src/reducer.js +36 -1
  42. package/plugins/interact/src/reducer.test.js +40 -1
  43. package/plugins/interact/src/utils/interactionModes.js +12 -0
  44. package/src/App/components/Panel/Panel.jsx +6 -6
  45. package/src/App/components/Panel/Panel.test.jsx +37 -0
  46. package/src/App/components/Viewport/Viewport.jsx +5 -15
  47. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  48. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  49. package/src/App/hooks/useInterfaceAPI.js +7 -7
  50. package/src/App/hooks/useInterfaceAPI.test.js +15 -9
  51. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  52. package/src/App/layout/Layout.jsx +1 -1
  53. package/src/App/layout/layout.module.scss +1 -8
  54. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  55. package/src/App/renderer/mapPanels.js +2 -1
  56. package/src/App/store/appActionsMap.js +4 -4
  57. package/src/App/store/appActionsMap.test.js +10 -0
  58. package/src/InteractiveMap/InteractiveMap.js +59 -11
  59. package/src/InteractiveMap/InteractiveMap.test.js +126 -4
  60. package/src/InteractiveMap/domStateManager.js +18 -6
  61. package/src/InteractiveMap/domStateManager.test.js +21 -0
  62. package/src/InteractiveMap/historyManager.js +28 -16
  63. package/src/InteractiveMap/historyManager.test.js +17 -0
  64. package/src/config/appConfig.js +2 -1
  65. package/src/config/appConfig.test.js +3 -13
  66. package/src/config/defaults.js +2 -1
  67. package/src/config/events.js +20 -21
  68. package/src/services/closeApp.js +1 -10
  69. package/src/services/closeApp.test.js +3 -43
  70. package/src/types.js +6 -1
  71. package/src/utils/mapStateSync.js +48 -10
  72. package/src/utils/mapStateSync.test.js +29 -9
  73. package/docs/examples.mdx +0 -70
@@ -1,40 +1,38 @@
1
1
  import { attachEvents } from './events.js'
2
2
 
3
- describe('attachEvents', () => {
4
- let createParams, cleanup
5
-
6
- beforeEach(() => {
7
- jest.useFakeTimers()
8
- // factory function to create fresh params for each test
9
- createParams = () => {
10
- const appState = { layoutRefs: { viewportRef: { current: document.body } }, disabledButtons: new Set() }
11
- const pluginState = { dispatch: jest.fn(), selectionBounds: null, selectedFeatures: [], closeOnAction: true, multiSelect: false }
12
- const clickReadyRef = { current: false }
13
- return {
14
- appState,
15
- pluginState,
16
- clickReadyRef,
17
- getAppState: () => appState,
18
- getPluginState: () => pluginState,
19
- mapState: {
20
- markers: { remove: jest.fn(), getMarker: jest.fn(() => null) },
21
- crossHair: { getDetail: jest.fn(() => ({ point: { x: 0, y: 0 }, coords: [0, 0] })) }
22
- },
23
- buttonConfig: { selectDone: {}, selectAtTarget: {}, selectCancel: {} },
24
- events: { MAP_CLICK: 'map:click' },
25
- eventBus: { on: jest.fn(), off: jest.fn(), emit: jest.fn() },
26
- handleInteraction: jest.fn(),
27
- closeApp: jest.fn()
28
- }
29
- }
30
- })
31
-
32
- afterEach(() => {
33
- cleanup?.()
34
- jest.useRealTimers()
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: { x: 1, y: 2 }, coords: [3, 4] })
85
+ handler({ point: MOCK_POINT, coords: MOCK_COORDS })
83
86
 
84
- expect(params.handleInteraction).toHaveBeenCalledWith({ point: { x: 1, y: 2 }, coords: [3, 4] })
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: { x: 1, y: 2 }, coords: [3, 4] })
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: { x: 1, y: 2 }, coords: [3, 4] }
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
- // closeOnAction = true (already covered)
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('selectCancel emits cancel and respects closeOnAction', () => {
132
+ it('selectDone emits selectedFeatures and selectionBounds when no marker', () => {
127
133
  const params = createParams()
128
134
  cleanup = attachEvents(params)
129
135
 
130
- // closeOnAction = true
131
- params.buttonConfig.selectCancel.onClick()
132
- expect(params.closeApp).toHaveBeenCalled()
136
+ params.pluginState.selectedFeatures = [{ id: 'f1' }]
137
+ params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] }
138
+ params.buttonConfig.selectDone.onClick()
133
139
 
134
- // cover closeOnAction = false
135
- cleanup()
136
- const params2 = createParams()
137
- cleanup = attachEvents(params2)
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('does not emit or closeApp if selectDone button is disabled', () => {
146
+ it('selectDone includes selectedMarkers in payload when present', () => {
144
147
  const params = createParams()
145
148
  cleanup = attachEvents(params)
146
149
 
147
- params.appState.disabledButtons.add('selectDone')
150
+ params.pluginState.selectedMarkers = ['m1', 'm2']
148
151
  params.buttonConfig.selectDone.onClick()
149
152
 
150
- expect(params.eventBus.emit).not.toHaveBeenCalled()
151
- expect(params.closeApp).not.toHaveBeenCalled()
153
+ expect(params.eventBus.emit).toHaveBeenCalledWith(INTERACT_DONE,
154
+ expect.objectContaining({ selectedMarkers: ['m1', 'm2'] })
155
+ )
152
156
  })
153
157
 
154
- it('programmatic select/unselect dispatches and removes location', () => {
158
+ it('selectDone omits selectedMarkers from payload when empty', () => {
155
159
  const params = createParams()
156
160
  cleanup = attachEvents(params)
157
161
 
158
- const selectHandler = params.eventBus.on.mock.calls.find(c => c[0] === 'interact:selectFeature')[1]
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
- expect(params.pluginState.dispatch).toHaveBeenCalledTimes(2)
165
- expect(params.mapState.markers.remove).toHaveBeenCalledTimes(2)
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('cleanup removes all handlers', () => {
168
+ it('does not emit or closeApp if selectDone button is disabled', () => {
169
169
  const params = createParams()
170
170
  cleanup = attachEvents(params)
171
- cleanup()
172
- Object.values(params.buttonConfig).forEach(btn => expect(btn.onClick).toBeNull())
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('selectDone emits selectedFeatures and selectionBounds when no marker/coords', () => {
179
+ it('selectCancel emits cancel and respects closeOnAction', () => {
176
180
  const params = createParams()
177
181
  cleanup = attachEvents(params)
178
182
 
179
- params.mapState.markers.getMarker.mockReturnValue(null)
180
- params.pluginState.selectedFeatures = [{ id: 'f1' }]
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
- expect(params.eventBus.emit).toHaveBeenCalledWith('interact:done', {
186
- selectedFeatures: [{ id: 'f1' }],
187
- selectionBounds: { sw: [0, 0], ne: [1, 1] }
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('selectDone includes selectedMarkers in payload when present', () => {
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
- expect(params.eventBus.emit).toHaveBeenCalledWith('interact:done',
201
- expect.objectContaining({ selectedMarkers: ['m1', 'm2'] })
202
- )
202
+ params.closeApp.mockClear()
203
+ params.buttonConfig.selectCancel.onClick()
204
+ expect(params.closeApp).toHaveBeenCalledTimes(1)
203
205
  })
206
+ })
204
207
 
205
- it('selectDone omits selectedMarkers from payload when empty', () => {
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.mapState.markers.getMarker.mockReturnValue(null)
210
- params.pluginState.selectedMarkers = []
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
- params.buttonConfig.selectDone.onClick()
221
+ selectHandler({ featureId: 'F1' })
222
+ unselectHandler({ featureId: 'F2' })
213
223
 
214
- const payload = params.eventBus.emit.mock.calls.find(c => c[0] === 'interact:done')[1]
215
- expect(payload).not.toHaveProperty('selectedMarkers')
224
+ expect(params.pluginState.dispatch).toHaveBeenCalledTimes(2)
225
+ expect(params.mapState.markers.remove).toHaveBeenCalledTimes(2)
216
226
  })
227
+ })
217
228
 
218
- it('respects default closeOnAction when value is undefined (fallback to true)', () => {
229
+ describe('attachEvents cleanup', () => {
230
+ it('removes all handlers and nulls button onClick callbacks', () => {
231
+ jest.useFakeTimers()
219
232
  const params = createParams()
220
- // Explicitly set to undefined to trigger the ?? fallback
221
- params.pluginState.closeOnAction = undefined
222
- cleanup = attachEvents(params)
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
- hiddenWhen: ({ appState, pluginState }) => !pluginState.enabled || appState.interfaceType !== 'touch',
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 = Boolean(isModal || triggeringElement)
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 = ({ keyboardHintPortalRef }) => {
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 && keyboardHintPortalRef?.current && createPortal(
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
- keyboardHintPortalRef.current
74
+ mainRef.current
85
75
  )}
86
76
  <div className='im-c-viewport__map-container' ref={mapContainerRef} />
87
77
  <div className='im-c-viewport__features' />
@@ -31,8 +31,10 @@
31
31
 
32
32
  .im-c-viewport__keyboard-hint {
33
33
  position: absolute;
34
+ bottom: var(--keyboard-hint-bottom, var(--primary-gap));
34
35
  left: 50%;
35
36
  transform: translateX(-50%);
37
+ z-index: 1001;
36
38
  text-wrap: nowrap;
37
39
 
38
40
  color: var(--tooltip-foreground-color);