@defra/interactive-map 0.0.43-alpha → 0.0.44-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 (159) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +16 -8
  7. package/docs/plugins/draw.md +1 -1
  8. package/package.json +1 -1
  9. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  11. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  12. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  13. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  14. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  15. package/plugins/datasets/dist/css/index.css +1 -1
  16. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  17. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  18. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  19. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  20. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  21. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  22. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  23. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  24. package/plugins/draw/dist/esm/index.js +1 -1
  25. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  28. package/plugins/draw/dist/umd/index.js +1 -1
  29. package/plugins/draw/src/DrawInit.jsx +31 -26
  30. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  31. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  32. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  33. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  34. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  35. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  36. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  37. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  38. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +8 -2
  39. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +35 -0
  40. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  41. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  42. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  43. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  44. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  45. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  46. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  47. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  48. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  49. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  51. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  52. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  53. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  54. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  55. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  56. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  57. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  58. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  59. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  60. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  61. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  62. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  63. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  64. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  65. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  66. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  67. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  68. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  69. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  70. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +31 -17
  71. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  72. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  73. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  74. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  75. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  76. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  77. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  78. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  79. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  80. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +32 -57
  81. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +14 -13
  82. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  83. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  84. package/plugins/draw/src/events.js +4 -4
  85. package/plugins/draw/src/events.test.js +1 -1
  86. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  87. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  88. package/plugins/draw/src/utils/spatial.js +1 -31
  89. package/plugins/draw/src/utils/spatial.test.js +1 -24
  90. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  92. package/plugins/interact/dist/umd/index.js +1 -1
  93. package/plugins/interact/src/InteractInit.jsx +2 -2
  94. package/plugins/interact/src/InteractInit.test.js +3 -3
  95. package/plugins/interact/src/events.js +5 -0
  96. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  97. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  98. package/plugins/interact/src/hooks/useCrossHairVisibility.js +4 -4
  99. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  100. package/plugins/interact/src/hooks/{useMapItemList.js → useSpatialList.js} +87 -52
  101. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +152 -71
  102. package/plugins/interact/src/manifest.js +12 -1
  103. package/plugins/interact/src/manifest.test.js +20 -0
  104. package/plugins/interact/src/utils/spatial.js +1 -1
  105. package/providers/beta/openlayers/src/utils/queryFeatures.js +1 -1
  106. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  107. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  108. package/providers/maplibre/dist/umd/index.js +1 -1
  109. package/providers/maplibre/src/utils/labels.js +2 -2
  110. package/providers/maplibre/src/utils/labels.test.js +5 -5
  111. package/providers/maplibre/src/utils/spatial.js +0 -57
  112. package/providers/maplibre/src/utils/spatial.test.js +0 -27
  113. package/src/App/components/CrossHair/CrossHair.jsx +10 -4
  114. package/src/App/components/CrossHair/CrossHair.module.scss +16 -12
  115. package/src/App/components/CrossHair/CrossHair.test.jsx +9 -4
  116. package/src/App/components/Hints/Hints.jsx +3 -3
  117. package/src/App/components/Hints/Hints.test.jsx +3 -3
  118. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  119. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  120. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  121. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  122. package/src/App/components/Markers/Markers.jsx +2 -2
  123. package/src/App/components/Markers/Markers.test.jsx +3 -3
  124. package/src/App/components/Markers/SymbolMarker.test.jsx +2 -2
  125. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  126. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  127. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +3 -3
  128. package/src/App/components/Viewport/{Features.test.jsx → SpatialList.test.jsx} +60 -43
  129. package/src/App/components/Viewport/Viewport.jsx +20 -10
  130. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  131. package/src/App/hooks/useCrossHairAPI.js +5 -5
  132. package/src/App/hooks/useCrossHairAPI.test.js +5 -5
  133. package/src/App/hooks/useHintsAPI.js +5 -5
  134. package/src/App/hooks/useSpatialListFocus.js +296 -0
  135. package/src/App/hooks/{useFeatureFocus.test.js → useSpatialListFocus.test.js} +318 -163
  136. package/src/App/hooks/useSpatialListItems.js +51 -0
  137. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  138. package/src/App/initialiseApp.js +6 -3
  139. package/src/App/initialiseApp.test.js +11 -0
  140. package/src/App/registry/spatialListRegistry.js +104 -0
  141. package/src/App/registry/spatialListRegistry.test.js +218 -0
  142. package/src/App/store/AppProvider.jsx +3 -2
  143. package/src/config/appConfig.js +14 -14
  144. package/src/config/appConfig.test.js +19 -19
  145. package/src/config/defaults.js +2 -2
  146. package/src/config/events.js +8 -8
  147. package/src/scss/main.scss +2 -2
  148. package/src/test-utils.js +8 -0
  149. package/src/types.js +11 -9
  150. package/src/utils/findNearestItemInDirection.js +29 -0
  151. package/src/utils/findNearestItemInDirection.test.js +67 -0
  152. package/src/utils/globalAltShortcuts.js +12 -0
  153. package/src/utils/globalAltShortcuts.test.js +27 -0
  154. package/src/utils/spatialNavigate.js +49 -0
  155. package/src/utils/spatialNavigate.test.js +42 -0
  156. package/src/App/components/Viewport/Features.jsx +0 -49
  157. package/src/App/hooks/useFeatureFocus.js +0 -257
  158. package/src/App/hooks/useFeatureItems.js +0 -39
  159. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -35,8 +35,7 @@ export const EditVertexMode = {
35
35
  scale: options.scale ?? 1
36
36
  })
37
37
 
38
- // Clear undo stack only on initial entry to edit mode for a feature
39
- // Only clear if we're starting a new editing session (not already editing)
38
+ // Clear the undo stack only when starting a new editing session for this feature.
40
39
  if (this.map._lastEditFeatureId !== state.featureId) {
41
40
  this.map._undoStack?.clear()
42
41
  this.map._lastEditFeatureId = state.featureId
@@ -46,7 +45,7 @@ export const EditVertexMode = {
46
45
  const feature = this.getFeature(state.featureId)
47
46
  state.featureType = feature?.type
48
47
 
49
- state.vertecies = this.getVerticies(state.featureId)
48
+ state.vertices = this.getVertices(state.featureId)
50
49
  state.midpoints = this.getMidpoints(state.featureId)
51
50
  this.setupEventListeners(state)
52
51
 
@@ -59,7 +58,7 @@ export const EditVertexMode = {
59
58
 
60
59
  // Show touch target if entering with a selected vertex on touch interface
61
60
  if (state.interfaceType === 'touch' && state.selectedVertexIndex >= 0 && state.selectedVertexType === 'vertex') {
62
- const vertex = state.vertecies[state.selectedVertexIndex]
61
+ const vertex = state.vertices[state.selectedVertexIndex]
63
62
  if (vertex) {
64
63
  setTimeout(() => {
65
64
  this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale))
@@ -78,12 +77,16 @@ export const EditVertexMode = {
78
77
  const handlers = this.handlers = buildEditModeHandlers(this, state, {
79
78
  nudge: this.onNudgeVertex,
80
79
  selectionchange: this.onSelectionChange,
81
- update: this.onUpdate
80
+ update: this.onUpdate,
81
+ selectvertex: this.onSelectVertex,
82
+ insertvertexatmidpoint: this.onInsertVertexAtMidpoint
82
83
  })
83
84
 
84
85
  bindEditModeListeners(state, this.map, handlers)
85
86
  this.map.on('draw.selectionchange', handlers.selectionchange)
86
87
  this.map.on('draw.update', handlers.update)
88
+ this.map.on(CUSTOM_DRAW_EVENTS.SELECT_VERTEX, handlers.selectvertex)
89
+ this.map.on(CUSTOM_DRAW_EVENTS.INSERT_VERTEX_AT_MIDPOINT, handlers.insertvertexatmidpoint)
87
90
  },
88
91
 
89
92
  applyVertexSelection (state, options) {
@@ -92,7 +95,7 @@ export const EditVertexMode = {
92
95
  this.clearSelectedCoordinates()
93
96
  state.feature.changed()
94
97
  this._ctx.store.render()
95
- this.updateMidpoint(state.midpoints[options.selectedVertexIndex - state.vertecies.length])
98
+ this.updateMidpoint(state.midpoints[options.selectedVertexIndex - state.vertices.length])
96
99
  return
97
100
  }
98
101
  if (options.selectedVertexIndex === -1) {
@@ -104,13 +107,12 @@ export const EditVertexMode = {
104
107
  },
105
108
 
106
109
  onSelectionChange (state, event) {
107
- // Refresh vertex list so numVertecies reflects the latest geometry (e.g. after midpoint insertion)
110
+ // Refresh vertex list so numVertices reflects the latest geometry (e.g. after midpoint insertion)
108
111
  this.syncVertices(state)
109
112
 
110
113
  const vertexCoord = event.points[event.points.length - 1]?.geometry.coordinates
111
114
 
112
- // Only update selectedVertexIndex from event if not keyboard mode AND event has valid vertex
113
- // For keyboard mode or when we have coordPath, trust the existing selectedVertexIndex
115
+ // For keyboard mode or when coordPath is set, trust the existing selectedVertexIndex.
114
116
  if (state.interfaceType !== 'keyboard' && vertexCoord && !state.coordPath) {
115
117
  // No coordPath available - need to search for vertex by coordinates
116
118
  const geom = event.features[0]?.geometry
@@ -123,11 +125,11 @@ export const EditVertexMode = {
123
125
 
124
126
  this.map.fire(CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, {
125
127
  index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1,
126
- numVertecies: state.vertecies.length
128
+ numVertices: state.vertices.length
127
129
  })
128
130
 
129
131
  // Use vertex from event if available, otherwise fall back to state
130
- const vertex = vertexCoord || (state.selectedVertexIndex >= 0 ? state.vertecies[state.selectedVertexIndex] : null)
132
+ const vertex = vertexCoord || (state.selectedVertexIndex >= 0 ? state.vertices[state.selectedVertexIndex] : null)
131
133
  this.updateTouchVertexTarget(state, vertex ? scalePoint(this.map.project(vertex), state.scale) : null)
132
134
  },
133
135
 
@@ -137,13 +139,13 @@ export const EditVertexMode = {
137
139
 
138
140
  onInterfaceTypeChange (state, event) {
139
141
  state.interfaceType = event.interfaceType
140
- const vertex = state.selectedVertexIndex >= 0 ? state.vertecies[state.selectedVertexIndex] : null
142
+ const vertex = state.selectedVertexIndex >= 0 ? state.vertices[state.selectedVertexIndex] : null
141
143
  this.updateTouchVertexTarget(state, vertex ? scalePoint(this.map.project(vertex), state.scale) : null)
142
144
  },
143
145
 
144
146
  onUpdate (state) {
145
- const prev = new Set(state.vertecies.map(coordinate => JSON.stringify(coordinate)))
146
- if (prev.size === state.vertecies.length) {
147
+ const prev = new Set(state.vertices.map(coordinate => JSON.stringify(coordinate)))
148
+ if (prev.size === state.vertices.length) {
147
149
  return
148
150
  }
149
151
  // Duplicate coordinates exist (e.g. a self-touching ring). Comparing the list
@@ -153,26 +155,48 @@ export const EditVertexMode = {
153
155
  },
154
156
 
155
157
  onMove (state) {
156
- const vertex = state.vertecies[state.selectedVertexIndex]
158
+ const vertex = state.vertices[state.selectedVertexIndex]
157
159
  if (vertex) {
158
160
  this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale))
159
161
  }
160
162
  },
161
163
 
162
- // Inbound signal from MaplibreDrawAdapter.nudgeSelectedVertex — bridges into the
163
- // running mode the same way onInterfaceTypeChange does, since the adapter has no
164
- // direct reference to this mode's live state. Unlike OL (where setState's shared
165
- // onUpdate hook repositions the touch target for any vertex move), moveVertex
166
- // here has no equivalent hook, so the reposition has to happen explicitly —
167
- // same as onMove/onSelectionChange/onInterfaceTypeChange already do.
164
+ // Inbound signal from MaplibreDrawAdapter.nudgeSelectedVertex — bridges into the running
165
+ // mode since the adapter has no direct reference to its live state.
168
166
  onNudgeVertex (state, event) {
169
167
  this.nudgeVertexByDelta(state, event.dx, event.dy, event.isLargeStep)
170
- const vertex = state.vertecies[state.selectedVertexIndex]
168
+ const vertex = state.vertices[state.selectedVertexIndex]
171
169
  if (vertex) {
172
170
  this.updateTouchVertexTarget(state, scalePoint(this.map.project(vertex), state.scale))
173
171
  }
174
172
  },
175
173
 
174
+ // Inbound signal from MaplibreDrawAdapter.selectVertex — previews a vertex/midpoint by flat
175
+ // index (no geometry change) for the spatial listbox's roving move.
176
+ onSelectVertex (state, event) {
177
+ if (!state.vertices?.length) { // lazy-populate guard, same as startKeyboardSelection
178
+ state.vertices = this.getVertices(state.featureId)
179
+ state.midpoints = this.getMidpoints(state.featureId)
180
+ }
181
+ const type = event.index < state.vertices.length ? 'vertex' : 'midpoint'
182
+ this.changeMode(state, {
183
+ selectedVertexIndex: event.index,
184
+ selectedVertexType: type,
185
+ ...(type === 'vertex' && { coordPath: this.getCoordPath(state, event.index) })
186
+ })
187
+ },
188
+
189
+ // Inbound signal from MaplibreDrawAdapter.insertVertexAtMidpoint — commits a new vertex at
190
+ // midpoint `event.index` for the spatial listbox's confirm, landing exactly on the midpoint
191
+ // (no offset), same as touchHandlers.js's onTap does for a midpoint tap.
192
+ onInsertVertexAtMidpoint (state, event) {
193
+ if (!state.vertices?.length) {
194
+ state.vertices = this.getVertices(state.featureId)
195
+ state.midpoints = this.getMidpoints(state.featureId)
196
+ }
197
+ this.insertVertex({ ...state, selectedVertexIndex: event.index, selectedVertexType: 'midpoint' })
198
+ },
199
+
176
200
  onButtonClick (state, event) {
177
201
  if (event.target.closest(`#${state.deleteVertexButtonId}`) && state.selectedVertexType === 'vertex') {
178
202
  this.deleteVertex(state)
@@ -200,6 +224,8 @@ export const EditVertexMode = {
200
224
  const handlers = this.handlers
201
225
  this.map.off('draw.selectionchange', handlers.selectionchange)
202
226
  this.map.off('draw.update', handlers.update)
227
+ this.map.off(CUSTOM_DRAW_EVENTS.SELECT_VERTEX, handlers.selectvertex)
228
+ this.map.off(CUSTOM_DRAW_EVENTS.INSERT_VERTEX_AT_MIDPOINT, handlers.insertvertexatmidpoint)
203
229
  unbindEditModeListeners(state, this.map, handlers)
204
230
  this.hideTouchVertexIndicator(state)
205
231
  }
@@ -1,4 +1,5 @@
1
1
  import { createHarness, POLYGON } from './editVertexMode/__helpers__/harness.js'
2
+ import { CUSTOM_DRAW_EVENTS } from '../drawEvents.js'
2
3
 
3
4
  /**
4
5
  * Tests for EditVertexMode's own methods: setup/teardown lifecycle, selection/scale/update
@@ -12,7 +13,7 @@ describe('onSetup / onStop lifecycle', () => {
12
13
  const { ctx, state, map } = createHarness()
13
14
  expect(state.featureId).toBe('feat-1')
14
15
  // Real PolygonFeature stores rings without the duplicate closing coordinate
15
- expect(state.vertecies).toHaveLength(4)
16
+ expect(state.vertices).toHaveLength(4)
16
17
  expect(state.midpoints).toHaveLength(4)
17
18
  expect(map._lastEditFeatureId).toBe('feat-1')
18
19
  expect(map._drawEditContainer).toBe(state.container)
@@ -73,7 +74,7 @@ describe('selection, scale and update events', () => {
73
74
  const geom = ctx.getFeature('feat-1').toGeoJSON().geometry
74
75
  ctx.onSelectionChange(state, { points: [{ geometry: { coordinates: [10, 0] } }], features: [{ geometry: geom }] })
75
76
  expect(state.selectedVertexIndex).toBe(1)
76
- expect(map.fire).toHaveBeenCalledWith('draw.vertexselection', expect.objectContaining({ numVertecies: 4 }))
77
+ expect(map.fire).toHaveBeenCalledWith('draw.vertexselection', expect.objectContaining({ numVertices: 4 }))
77
78
 
78
79
  state.coordPath = '0.2'
79
80
  state.selectedVertexIndex = 2
@@ -101,14 +102,14 @@ describe('selection, scale and update events', () => {
101
102
  ctx.onInterfaceTypeChange({ ...state, selectedVertexIndex: -1 }, { interfaceType: 'mouse' })
102
103
  })
103
104
 
104
- test('draw.nudgevertex moves the selected vertex and repositions the touch target — the inbound bridge for MoveControls.mapProvider.activeMoveTarget', () => {
105
+ test('draw.nudgevertex moves the selected vertex and repositions the touch target — the inbound bridge for MapControls.mapProvider.activeMoveTarget', () => {
105
106
  jest.useFakeTimers()
106
107
  const { state, map } = createHarness(POLYGON(), { interfaceType: 'touch', selectedVertexIndex: 0, selectedVertexType: 'vertex' })
107
108
  jest.runAllTimers() // flush onSetup's deferred initial touch-target positioning
108
- const before = [...state.vertecies[0]]
109
+ const before = [...state.vertices[0]]
109
110
  const targetBefore = { top: state.touchVertexTarget.style.top, left: state.touchVertexTarget.style.left }
110
111
  map.fire('draw.nudgevertex', { dx: 1, dy: 0, isLargeStep: true })
111
- expect(state.vertecies[0]).not.toEqual(before)
112
+ expect(state.vertices[0]).not.toEqual(before)
112
113
  expect({ top: state.touchVertexTarget.style.top, left: state.touchVertexTarget.style.left }).not.toEqual(targetBefore)
113
114
  })
114
115
 
@@ -120,7 +121,7 @@ describe('selection, scale and update events', () => {
120
121
  test('onUpdate re-selects a changed vertex only when the vertex count is ambiguous', () => {
121
122
  const { ctx, state } = createHarness()
122
123
  ctx.onUpdate(state) // unique coords → no change
123
- state.vertecies = [[0, 0], [0, 0], [1, 1]]
124
+ state.vertices = [[0, 0], [0, 0], [1, 1]]
124
125
  ctx.onUpdate(state)
125
126
  expect(state.selectedVertexIndex).toBe(-1)
126
127
  })
@@ -158,3 +159,70 @@ describe('move, button and changeMode routing', () => {
158
159
  expect(api.changeMode).not.toHaveBeenCalled()
159
160
  })
160
161
  })
162
+
163
+ // onSelectVertex/onInsertVertexAtMidpoint bridge MaplibreDrawAdapter.selectVertex/
164
+ // insertVertexAtMidpoint into the running mode (see drawEvents.js's SELECT_VERTEX/
165
+ // INSERT_VERTEX_AT_MIDPOINT) for the shared spatial listbox.
166
+ describe('onSelectVertex / onInsertVertexAtMidpoint (spatial listbox bridge)', () => {
167
+ test('registers the select-vertex/insert-vertex-at-midpoint listeners on setup', () => {
168
+ const { map } = createHarness()
169
+ expect(map.on).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.SELECT_VERTEX, expect.any(Function))
170
+ expect(map.on).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.INSERT_VERTEX_AT_MIDPOINT, expect.any(Function))
171
+ })
172
+
173
+ test('onSelectVertex moves the cursor to a real vertex, including its coordPath', () => {
174
+ const { ctx, state } = createHarness()
175
+ const changeModeSpy = jest.spyOn(ctx, 'changeMode')
176
+ ctx.onSelectVertex(state, { index: 2 })
177
+ expect(changeModeSpy).toHaveBeenCalledWith(state, {
178
+ selectedVertexIndex: 2,
179
+ selectedVertexType: 'vertex',
180
+ coordPath: expect.any(String)
181
+ })
182
+ })
183
+
184
+ test('onSelectVertex moves the cursor to a midpoint, without a coordPath', () => {
185
+ const { ctx, state } = createHarness()
186
+ const changeModeSpy = jest.spyOn(ctx, 'changeMode')
187
+ const midpointIndex = state.vertices.length
188
+ ctx.onSelectVertex(state, { index: midpointIndex })
189
+ expect(changeModeSpy).toHaveBeenCalledWith(state, {
190
+ selectedVertexIndex: midpointIndex,
191
+ selectedVertexType: 'midpoint'
192
+ })
193
+ })
194
+
195
+ test('onSelectVertex lazily (re)populates vertices/midpoints when not already cached', () => {
196
+ const { ctx, state } = createHarness()
197
+ state.vertices = []
198
+ state.midpoints = []
199
+ const changeModeSpy = jest.spyOn(ctx, 'changeMode')
200
+ ctx.onSelectVertex(state, { index: 0 })
201
+ expect(state.vertices.length).toBeGreaterThan(0)
202
+ expect(changeModeSpy).toHaveBeenCalled()
203
+ })
204
+
205
+ test('onInsertVertexAtMidpoint commits a new vertex exactly at the midpoint — no directional offset', () => {
206
+ const { ctx, state } = createHarness()
207
+ const midpointFlatIndex = state.vertices.length // first midpoint, between vertex 0 and 1
208
+ const expectedCoord = state.midpoints[0]
209
+ ctx.onInsertVertexAtMidpoint(state, { index: midpointFlatIndex })
210
+ const updated = ctx.getVertices(state.featureId)
211
+ expect(updated).toHaveLength(state.vertices.length + 1)
212
+ expect(updated[1]).toEqual(expectedCoord)
213
+ })
214
+
215
+ test('onInsertVertexAtMidpoint lazily (re)populates vertices/midpoints when not already cached', () => {
216
+ const { ctx, state } = createHarness()
217
+ state.vertices = []
218
+ state.midpoints = []
219
+ expect(() => ctx.onInsertVertexAtMidpoint(state, { index: 4 })).not.toThrow()
220
+ })
221
+
222
+ test('unregisters the select-vertex/insert-vertex-at-midpoint listeners on stop', () => {
223
+ const { ctx, state, map } = createHarness()
224
+ ctx.onStop(state)
225
+ expect(map.off).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.SELECT_VERTEX, expect.any(Function))
226
+ expect(map.off).toHaveBeenCalledWith(CUSTOM_DRAW_EVENTS.INSERT_VERTEX_AT_MIDPOINT, expect.any(Function))
227
+ })
228
+ })
@@ -20,7 +20,7 @@ export const sharedSnapMovement = {
20
20
  },
21
21
 
22
22
  // Explicit-delta counterpart to getOffset, driven by a unit direction vector and
23
- // MoveControls' own Precision toggle rather than a KeyboardEvent — used by
23
+ // MapControls' own Precision toggle rather than a KeyboardEvent — used by
24
24
  // nudgeVertexByDelta/nudgePointByDelta.
25
25
  getOffsetByDelta (coord, dx, dy, isLargeStep) {
26
26
  const pt = this.map.project(coord)
@@ -30,7 +30,7 @@ export const sharedSnapMovement = {
30
30
 
31
31
  // Resolves the destination coordinate for a nudge by (dx, dy) unit direction, applying
32
32
  // snap or breaking out of an already-active one — shared by the keyboard arrow-key path
33
- // and MoveControls' explicit-delta path so both snap identically. dx/dy are only needed
33
+ // and MapControls' explicit-delta path so both snap identically. dx/dy are only needed
34
34
  // for the snap-escape offset; getCandidate computes the raw, un-snapped destination the
35
35
  // caller would otherwise have used.
36
36
  resolveSnapTarget (state, dx, dy, currentCoord, getCandidate) {
@@ -26,6 +26,7 @@ const DRAW_OUTLINE_STYLE_LAYER = 'stroke-inactive.cold'
26
26
  * setInterfaceType(type)
27
27
  * done() / cancel() / undo() / deleteVertex()
28
28
  * nudgeSelectedVertex(dx, dy, isLargeStep)
29
+ * getVertexItems() / selectVertex(index) / insertVertexAtMidpoint(index)
29
30
  * get(id) / add(feature) / setStyle(id, properties) / delete(id) / deleteAll()
30
31
  * setSnapEnabled(bool) / setSnapLayers(layers) / isSnapEnabled()
31
32
  * setFeatureProperty(id, property, value) / setDrawingPreviewProperty(property, value)
@@ -77,6 +78,11 @@ export class OLDrawAdapter {
77
78
  deleteVertex () { this._manager.deleteVertex() }
78
79
  nudgeSelectedVertex (dx, dy, isLargeStep) { this._manager.nudgeSelectedVertex(dx, dy, isLargeStep) }
79
80
 
81
+ // Read-only vertex/midpoint coordinates for the shared spatial listbox.
82
+ getVertexItems () { return this._manager.getVertexItems() }
83
+ selectVertex (index) { this._manager.selectVertex(index) }
84
+ insertVertexAtMidpoint (index) { this._manager.insertVertexAtMidpoint(index) }
85
+
80
86
  // Record the current geometry validity so the draw mode can block finish gestures
81
87
  // (double-click / click-to-close) while the in-progress shape is invalid.
82
88
  setGeometryValid (valid) { this._manager._geometryValid = valid }
@@ -17,6 +17,9 @@ const fakeManager = () => ({
17
17
  undo: jest.fn(),
18
18
  deleteVertex: jest.fn(),
19
19
  nudgeSelectedVertex: jest.fn(),
20
+ getVertexItems: jest.fn(() => ({ vertices: [[0, 0]], midpoints: [] })),
21
+ selectVertex: jest.fn(),
22
+ insertVertexAtMidpoint: jest.fn(),
20
23
  setInvalid: jest.fn(),
21
24
  setDrawingPreviewProperty: jest.fn(),
22
25
  get: jest.fn(() => 'feature'),
@@ -136,6 +139,12 @@ test('remaining calls delegate straight through; setFeatureProperty is a deliber
136
139
  expect(manager.undo).toHaveBeenCalled()
137
140
  expect(manager.deleteVertex).toHaveBeenCalled()
138
141
  expect(manager.nudgeSelectedVertex).toHaveBeenCalledWith(1, 0, true)
142
+
143
+ expect(adapter.getVertexItems()).toEqual({ vertices: [[0, 0]], midpoints: [] })
144
+ adapter.selectVertex(2)
145
+ adapter.insertVertexAtMidpoint(3)
146
+ expect(manager.selectVertex).toHaveBeenCalledWith(2)
147
+ expect(manager.insertVertexAtMidpoint).toHaveBeenCalledWith(3)
139
148
  })
140
149
 
141
150
  // A directly-added Point skips draw_point's own icon-resolving drawend handler.
@@ -82,8 +82,8 @@ export class OLDrawManager {
82
82
  // Array.from, not [...handlers] — see the comment in utils/eventBus.js:
83
83
  // under a loose-mode Babel build (Docusaurus's docs site), spreading a Set
84
84
  // compiles to [].concat(handlers), which doesn't flatten it — it appends
85
- // the whole Set as one non-function element, and h(...) throws.
86
- if (handlers) { Array.from(handlers).forEach(h => h(detail)) }
85
+ // the whole Set as one non-function element, and handler(...) throws.
86
+ if (handlers) { Array.from(handlers).forEach(handler => handler(detail)) }
87
87
  }
88
88
 
89
89
  // --- Mode machine ---
@@ -146,6 +146,19 @@ export class OLDrawManager {
146
146
  this._modeInstance?.nudgeSelectedVertex?.(dx, dy, isLargeStep)
147
147
  }
148
148
 
149
+ // Read-only vertex/midpoint coordinates for the shared spatial listbox.
150
+ getVertexItems () {
151
+ return this._modeInstance?.getVertexItems?.() ?? { vertices: [], midpoints: [] }
152
+ }
153
+
154
+ selectVertex (index) {
155
+ this._modeInstance?.selectVertex?.(index)
156
+ }
157
+
158
+ insertVertexAtMidpoint (index) {
159
+ this._modeInstance?.insertVertexAtMidpoint?.(index)
160
+ }
161
+
149
162
  // Show/hide the dashed invalid stroke on the active draw sketch or edit feature.
150
163
  setInvalid (invalid) {
151
164
  this._modeInstance?.setInvalid?.(invalid)
@@ -14,7 +14,19 @@ jest.mock('../draw/DrawMode.js', () => ({
14
14
  createDrawMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), nudgeSelectedVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn(), setDrawingPreviewProperty: jest.fn() }))
15
15
  }))
16
16
  jest.mock('../edit/EditMode.js', () => ({
17
- createEditMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), nudgeSelectedVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn() }))
17
+ createEditMode: jest.fn(() => ({
18
+ destroy: jest.fn(),
19
+ done: jest.fn(),
20
+ cancel: jest.fn(),
21
+ undo: jest.fn(),
22
+ deleteVertex: jest.fn(),
23
+ nudgeSelectedVertex: jest.fn(),
24
+ setInterfaceType: jest.fn(),
25
+ setInvalid: jest.fn(),
26
+ getVertexItems: jest.fn(() => ({ vertices: [[0, 0]], midpoints: [] })),
27
+ selectVertex: jest.fn(),
28
+ insertVertexAtMidpoint: jest.fn()
29
+ }))
18
30
  }))
19
31
  jest.mock('../point/editPointMode.js', () => ({
20
32
  createEditPointMode: jest.fn(() => ({ destroy: jest.fn(), done: jest.fn(), cancel: jest.fn(), undo: jest.fn(), deleteVertex: jest.fn(), nudgeSelectedVertex: jest.fn(), setInterfaceType: jest.fn(), setInvalid: jest.fn() }))
@@ -109,6 +121,8 @@ describe('mode machine', () => {
109
121
  const { manager } = setup()
110
122
  manager.done(); manager.undo(); manager.deleteVertex(); manager.nudgeSelectedVertex(1, 0, true); manager.setInterfaceType('touch'); manager.setInvalid(true) // no mode — no throw
111
123
  manager.setDrawingPreviewProperty('splitter', 'valid') // no mode — no throw
124
+ expect(manager.getVertexItems()).toEqual({ vertices: [], midpoints: [] }) // no mode — safe default
125
+ manager.selectVertex(1); manager.insertVertexAtMidpoint(1) // no mode — no throw
112
126
 
113
127
  await manager.changeMode('draw_polygon')
114
128
  const instance = createDrawMode.mock.results[0].value
@@ -135,6 +149,18 @@ describe('mode machine', () => {
135
149
  expect(instance.cancel).toHaveBeenCalled()
136
150
  expect(manager.getMode()).toBe('disabled')
137
151
  })
152
+
153
+ test('getVertexItems/selectVertex/insertVertexAtMidpoint delegate to the edit_vertex mode instance', async () => {
154
+ const { manager } = setup()
155
+ await manager.changeMode('edit_vertex', { featureId: 'f1' })
156
+ const instance = createEditMode.mock.results[0].value
157
+
158
+ expect(manager.getVertexItems()).toEqual({ vertices: [[0, 0]], midpoints: [] })
159
+ manager.selectVertex(2)
160
+ manager.insertVertexAtMidpoint(3)
161
+ expect(instance.selectVertex).toHaveBeenCalledWith(2)
162
+ expect(instance.insertVertexAtMidpoint).toHaveBeenCalledWith(3)
163
+ })
138
164
  })
139
165
 
140
166
  test('undo stack changes are published on the adapter bus; off unsubscribes', () => {
@@ -275,7 +275,7 @@ describe('drawing lifecycle', () => {
275
275
  expect(changed).toHaveBeenCalled()
276
276
  })
277
277
 
278
- test('setInterfaceType forwards to the draw input (e.g. DrawInit syncing MoveControls-driven interface changes mid-session)', () => {
278
+ test('setInterfaceType forwards to the draw input (e.g. DrawInit syncing MapControls-driven interface changes mid-session)', () => {
279
279
  const { mode, input } = setup('Polygon')
280
280
  mode.setInterfaceType('touch')
281
281
  expect(input.setInterfaceType).toHaveBeenCalledWith('touch')
@@ -1,4 +1,5 @@
1
1
  import { createVertexPlacement } from './vertexPlacement.js'
2
+ import { stopIfGlobalAltKey } from '../../../../../../src/utils/globalAltShortcuts.js'
2
3
 
3
4
  const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'])
4
5
 
@@ -23,40 +24,47 @@ export const wireInputEvents = ({
23
24
  getInterfaceType, setInterfaceType, clearLastCoord,
24
25
  updateRubberbanding, placeVertex
25
26
  }) => {
27
+ // Normally gated to touch/keyboard — a real mouse cursor drives the candidate via its own
28
+ // pointermove instead. But Voice Control's simulated clicks report pointerType 'mouse' (so
29
+ // interfaceType never leaves 'mouse') and produce no pointermove at all, so panning via
30
+ // MapControls while interfaceType is 'mouse' would otherwise leave the candidate stale.
31
+ // olView.getAnimating() is also true for MapControls' own panBy/zoomIn/zoomOut (both use
32
+ // view.animate) but not for a live mouse drag (which sets the center directly, no
33
+ // animation) — the same fallback signal used below in onMapRender.
26
34
  const onCenterChange = () => {
27
- if (getInterfaceType() !== 'mouse') {
35
+ if (getInterfaceType() !== 'mouse' || olView?.getAnimating()) {
28
36
  updateRubberbanding()
29
37
  }
30
38
  }
31
39
  olView?.on('change:center', onCenterChange)
32
40
 
33
- const onKeydown = (e) => {
41
+ const onKeydown = (event) => {
34
42
  if (!container.contains(document.activeElement)) {
35
43
  return
36
44
  }
37
- if (ARROW_KEYS.has(e.key)) {
45
+ if (ARROW_KEYS.has(event.key)) {
38
46
  setInterfaceType('keyboard')
39
47
  return
40
48
  }
41
- if (e.key === 'Enter') {
42
- e.preventDefault()
49
+ if (event.key === 'Enter') {
50
+ event.preventDefault()
43
51
  setInterfaceType('keyboard')
44
52
  placeVertex()
45
53
  }
46
- if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
47
- e.preventDefault()
54
+ if (event.key === 'z' && (event.metaKey || event.ctrlKey)) {
55
+ event.preventDefault()
48
56
  onUndo?.()
49
57
  }
50
58
  }
51
59
 
52
- const onButtonClick = (e) => {
53
- if (addVertexButtonId && e.target.closest(`#${addVertexButtonId}`)) {
60
+ const onButtonClick = (event) => {
61
+ if (addVertexButtonId && event.target.closest(`#${addVertexButtonId}`)) {
54
62
  placeVertex()
55
63
  }
56
64
  }
57
65
 
58
- const onPointerdown = (e) => {
59
- if (e.pointerType !== 'touch') {
66
+ const onPointerdown = (event) => {
67
+ if (event.pointerType !== 'touch') {
60
68
  setInterfaceType('mouse')
61
69
  clearLastCoord()
62
70
  }
@@ -78,6 +86,9 @@ export const wireInputEvents = ({
78
86
  container.addEventListener('pointerdown', onPointerdown)
79
87
  container.addEventListener('touchstart', onTouchstart, { passive: true })
80
88
  container.addEventListener('pointermove', onPointerMove)
89
+ // Capture phase, separate from onKeydown above — shadows global Alt+<key> shortcuts
90
+ // (src/utils/globalAltShortcuts.js) unconditionally while drawing.
91
+ globalThis.addEventListener('keyup', stopIfGlobalAltKey, { capture: true })
81
92
 
82
93
  return {
83
94
  destroy () {
@@ -87,6 +98,7 @@ export const wireInputEvents = ({
87
98
  container.removeEventListener('pointerdown', onPointerdown)
88
99
  container.removeEventListener('touchstart', onTouchstart)
89
100
  container.removeEventListener('pointermove', onPointerMove)
101
+ globalThis.removeEventListener('keyup', stopIfGlobalAltKey, { capture: true })
90
102
  }
91
103
  }
92
104
  }
@@ -108,8 +120,7 @@ export const createDrawInput = ({ drawInteraction, options }) => {
108
120
  mapProvider,
109
121
  snap,
110
122
  canFinish,
111
- canPlace,
112
- getInterfaceType
123
+ canPlace
113
124
  })
114
125
 
115
126
  const map = drawInteraction.getMap()
@@ -121,7 +132,7 @@ export const createDrawInput = ({ drawInteraction, options }) => {
121
132
  olView,
122
133
  onUndo,
123
134
  getInterfaceType,
124
- setInterfaceType: (t) => { interfaceType = t; applyCrossHairVisibility(crossHair, t) },
135
+ setInterfaceType: (type) => { interfaceType = type; applyCrossHairVisibility(crossHair, type) },
125
136
  clearLastCoord: placement.clearLastCoord,
126
137
  updateRubberbanding: placement.updateRubberbanding,
127
138
  placeVertex: placement.placeVertex
@@ -138,9 +149,12 @@ export const createDrawInput = ({ drawInteraction, options }) => {
138
149
  crossHair.activate = placement.placeVertex
139
150
  }
140
151
 
141
- // change:center fires once when a keyboard pan animation starts; postrender tracks each frame.
152
+ // change:center fires once when an animated pan starts; postrender tracks each frame.
153
+ // getAnimating() alone (not also gated on interfaceType, unlike onCenterChange above) is
154
+ // enough here: it's already only true for an animated move, which is what needs a
155
+ // per-frame follow-up regardless of what interfaceType happens to be.
142
156
  const onMapRender = () => {
143
- if (interfaceType !== 'mouse' && olView?.getAnimating()) {
157
+ if (olView?.getAnimating()) {
144
158
  placement.updateRubberbanding()
145
159
  }
146
160
  }
@@ -149,7 +163,7 @@ export const createDrawInput = ({ drawInteraction, options }) => {
149
163
  return {
150
164
  getInterfaceType,
151
165
  // Called when the global interface type changes without any pointer/touch/key
152
- // event landing on the map container (e.g. panning via MoveControls after
166
+ // event landing on the map container (e.g. panning via MapControls after
153
167
  // switching to touch) — refresh the rubber band immediately rather than
154
168
  // waiting for the next incidental change:center/postrender event.
155
169
  setInterfaceType (type) {
@@ -56,6 +56,41 @@ test('arrow keys switch to the keyboard interface without placing', () => {
56
56
  expect(placement.placeVertex).not.toHaveBeenCalled()
57
57
  })
58
58
 
59
+ describe('global Alt+<key> shortcut shadowing', () => {
60
+ test('Alt+Enter keyup stops propagation while drawing — never reaches the app-wide "highlight label at center" shortcut', () => {
61
+ setup()
62
+ const event = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, cancelable: true, bubbles: true })
63
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
64
+ window.dispatchEvent(event)
65
+ expect(stopSpy).toHaveBeenCalled()
66
+ })
67
+
68
+ test('Alt+Arrow keyup stops propagation too — never reaches the app-wide "highlight next label" shortcut', () => {
69
+ setup()
70
+ const event = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: true, cancelable: true, bubbles: true })
71
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
72
+ window.dispatchEvent(event)
73
+ expect(stopSpy).toHaveBeenCalled()
74
+ })
75
+
76
+ test('a plain Enter keyup (no Alt) is left alone', () => {
77
+ setup()
78
+ const event = new KeyboardEvent('keyup', { key: 'Enter', altKey: false, cancelable: true, bubbles: true })
79
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
80
+ window.dispatchEvent(event)
81
+ expect(stopSpy).not.toHaveBeenCalled()
82
+ })
83
+
84
+ test('the shadow listener is removed on destroy — Alt+Enter bubbles normally again', () => {
85
+ const { input } = setup()
86
+ input.destroy()
87
+ const event = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, cancelable: true, bubbles: true })
88
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
89
+ window.dispatchEvent(event)
90
+ expect(stopSpy).not.toHaveBeenCalled()
91
+ })
92
+ })
93
+
59
94
  test('ctrl/cmd+z triggers undo', () => {
60
95
  const { container, onUndo } = setup()
61
96
  container.focus()
@@ -111,7 +146,26 @@ test('pointer moves and map pans update the rubber band except for the mouse int
111
146
  expect(mouse.placement.updateRubberbanding).not.toHaveBeenCalled()
112
147
  })
113
148
 
114
- test('setInterfaceType updates the interface and refreshes the rubber band immediately for non-mouse types, e.g. switching to touch and panning via MoveControls mid-session', () => {
149
+ // Voice Control's simulated clicks report pointerType 'mouse' (interfaceType never leaves
150
+ // 'mouse') and produce no real pointermove, so panning via MapControls needs its own
151
+ // fallback: getAnimating() is true for MapControls' own panBy/zoomIn/zoomOut (both animate
152
+ // the view) but not for a live mouse drag (which sets the center directly), so it's a safe
153
+ // signal to also track even while interfaceType is 'mouse'.
154
+ test('a programmatic move (e.g. MapControls panBy) still updates the rubber band while interfaceType is mouse', () => {
155
+ const { view, placement } = setup('mouse')
156
+ view.getAnimating.mockReturnValue(true)
157
+ view.emit('change:center')
158
+ expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1)
159
+ })
160
+
161
+ test('postrender tracks an animated move for the mouse interface too, same as keyboard', () => {
162
+ const { map, view, placement } = setup('mouse')
163
+ view.getAnimating.mockReturnValue(true)
164
+ map.emit('postrender')
165
+ expect(placement.updateRubberbanding).toHaveBeenCalledTimes(1)
166
+ })
167
+
168
+ test('setInterfaceType updates the interface and refreshes the rubber band immediately for non-mouse types, e.g. switching to touch and panning via MapControls mid-session', () => {
115
169
  const { input, placement } = setup('mouse')
116
170
  input.setInterfaceType('touch')
117
171
  expect(input.getInterfaceType()).toBe('touch')