@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
@@ -10,7 +10,7 @@ import { useMediaQueryDispatch } from '../hooks/useMediaQueryDispatch.js'
10
10
  export const AppContext = createContext(null)
11
11
 
12
12
  export const AppProvider = ({ options, children }) => {
13
- const { pluginRegistry, buttonRegistry, panelRegistry, controlRegistry, keyboardShortcutRegistry, eventBus, breakpointDetector } = options
13
+ const { pluginRegistry, buttonRegistry, panelRegistry, controlRegistry, keyboardShortcutRegistry, spatialListRegistry, eventBus, breakpointDetector } = options
14
14
 
15
15
  const layoutRefs = {
16
16
  appContainerRef: useRef(null),
@@ -96,7 +96,8 @@ export const AppProvider = ({ options, children }) => {
96
96
  buttonRegistry,
97
97
  panelRegistry,
98
98
  controlRegistry,
99
- keyboardShortcutRegistry
99
+ keyboardShortcutRegistry,
100
+ spatialListRegistry
100
101
  }), [state, dispatch])
101
102
 
102
103
  return (
@@ -1,5 +1,5 @@
1
1
  import { KeyboardHelp } from '../App/components/KeyboardHelp/KeyboardHelp.jsx'
2
- import { MoveControls } from '../App/components/MoveControls/MoveControls.jsx'
2
+ import { MapControls } from '../App/components/MapControls/MapControls.jsx'
3
3
 
4
4
  const keyboardBasePanelSlots = {
5
5
  slot: 'middle',
@@ -13,7 +13,7 @@ const buttonSlots = {
13
13
  showLabel: false
14
14
  }
15
15
 
16
- const moveControlsSlot = {
16
+ const mapControlsSlot = {
17
17
  slot: 'right-bottom'
18
18
  }
19
19
 
@@ -79,7 +79,7 @@ export const defaultAppConfig = {
79
79
  iconId: 'plus',
80
80
  keepFocus: true,
81
81
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta),
82
- excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControls || appState.interfaceType === 'touch',
82
+ excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMapControls || appState.interfaceType === 'touch',
83
83
  enableWhen: ({ mapState }) => !mapState.isAtMaxZoom,
84
84
  mobile: buttonSlots,
85
85
  tablet: buttonSlots,
@@ -91,23 +91,23 @@ export const defaultAppConfig = {
91
91
  iconId: 'minus',
92
92
  keepFocus: true,
93
93
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta),
94
- excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControls || appState.interfaceType === 'touch',
94
+ excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMapControls || appState.interfaceType === 'touch',
95
95
  enableWhen: ({ mapState }) => !mapState.isAtMinZoom,
96
96
  mobile: buttonSlots,
97
97
  tablet: buttonSlots,
98
98
  desktop: buttonSlots
99
99
  }, {
100
- id: 'moveControls',
100
+ id: 'mapControls',
101
101
  label: 'Map controls',
102
102
  iconId: 'move',
103
103
  keepFocus: true,
104
104
  isExpanded: false,
105
- ariaControls: ({ appConfig }) => `${appConfig.id}-move-controls-content`,
105
+ ariaControls: ({ appConfig }) => `${appConfig.id}-map-controls-content`,
106
106
  onClick: (_e, { appState }) => appState.dispatch({
107
107
  type: 'TOGGLE_BUTTON_EXPANDED',
108
- payload: { id: 'moveControls', isExpanded: !appState.expandedButtons.has('moveControls') }
108
+ payload: { id: 'mapControls', isExpanded: !appState.expandedButtons.has('mapControls') }
109
109
  }),
110
- excludeWhen: ({ appConfig }) => !appConfig.enableMoveControls,
110
+ excludeWhen: ({ appConfig }) => !appConfig.enableMapControls,
111
111
  mobile: buttonSlots,
112
112
  tablet: buttonSlots,
113
113
  desktop: buttonSlots
@@ -131,13 +131,13 @@ export const defaultAppConfig = {
131
131
  }],
132
132
 
133
133
  controls: [{
134
- id: 'moveControls',
134
+ id: 'mapControls',
135
135
  label: 'Move and zoom',
136
- excludeWhen: ({ appConfig }) => !appConfig.enableMoveControls,
137
- mobile: moveControlsSlot,
138
- tablet: moveControlsSlot,
139
- desktop: moveControlsSlot,
140
- render: MoveControls
136
+ excludeWhen: ({ appConfig }) => !appConfig.enableMapControls,
137
+ mobile: mapControlsSlot,
138
+ tablet: mapControlsSlot,
139
+ desktop: mapControlsSlot,
140
+ render: MapControls
141
141
  }],
142
142
 
143
143
  icons: [{
@@ -19,7 +19,7 @@ describe('defaultAppConfig', () => {
19
19
  const journeyContinueBtn = buttons.find(b => b.id === 'journeyContinue')
20
20
  const zoomInBtn = buttons.find(b => b.id === 'zoomIn')
21
21
  const zoomOutBtn = buttons.find(b => b.id === 'zoomOut')
22
- const moveControlsBtn = buttons.find(b => b.id === 'moveControls')
22
+ const mapControlsBtn = buttons.find(b => b.id === 'mapControls')
23
23
 
24
24
  // --- UI RENDER TESTS ---
25
25
  it('renders KeyboardHelp panel', () => {
@@ -144,21 +144,21 @@ describe('defaultAppConfig', () => {
144
144
  appState: { interfaceType: 'mouse' }
145
145
  })).toBe(true)
146
146
 
147
- // Branch B: Second part of OR is true (enableMoveControls)
147
+ // Branch B: Second part of OR is true (enableMapControls)
148
148
  expect(btn.excludeWhen({
149
- appConfig: { enableZoomControls: true, enableMoveControls: true },
149
+ appConfig: { enableZoomControls: true, enableMapControls: true },
150
150
  appState: { interfaceType: 'mouse' }
151
151
  })).toBe(true)
152
152
 
153
153
  // Branch C: Third part of OR is true (interfaceType === 'touch')
154
154
  expect(btn.excludeWhen({
155
- appConfig: { enableZoomControls: true, enableMoveControls: false },
155
+ appConfig: { enableZoomControls: true, enableMapControls: false },
156
156
  appState: { interfaceType: 'touch' }
157
157
  })).toBe(true)
158
158
 
159
159
  // Branch D: All parts are false (Result: false)
160
160
  expect(btn.excludeWhen({
161
- appConfig: { enableZoomControls: true, enableMoveControls: false },
161
+ appConfig: { enableZoomControls: true, enableMapControls: false },
162
162
  appState: { interfaceType: 'mouse' }
163
163
  })).toBe(false)
164
164
  })
@@ -183,37 +183,37 @@ describe('defaultAppConfig', () => {
183
183
  })
184
184
 
185
185
  // --- MOVE CONTROLS TOGGLE BUTTON ---
186
- it('covers all branches of moveControls excludeWhen', () => {
187
- expect(moveControlsBtn.excludeWhen({ appConfig: { enableMoveControls: false } })).toBe(true)
188
- expect(moveControlsBtn.excludeWhen({ appConfig: { enableMoveControls: true } })).toBe(false)
186
+ it('covers all branches of mapControls excludeWhen', () => {
187
+ expect(mapControlsBtn.excludeWhen({ appConfig: { enableMapControls: false } })).toBe(true)
188
+ expect(mapControlsBtn.excludeWhen({ appConfig: { enableMapControls: true } })).toBe(false)
189
189
  })
190
190
 
191
- it('moveControls ariaControls resolves the control element id', () => {
192
- expect(moveControlsBtn.ariaControls({ appConfig: { id: 'im' } })).toBe('im-move-controls-content')
191
+ it('mapControls ariaControls resolves the control element id', () => {
192
+ expect(mapControlsBtn.ariaControls({ appConfig: { id: 'im' } })).toBe('im-map-controls-content')
193
193
  })
194
194
 
195
- it('moveControls onClick toggles TOGGLE_BUTTON_EXPANDED based on current state', () => {
195
+ it('mapControls onClick toggles TOGGLE_BUTTON_EXPANDED based on current state', () => {
196
196
  const dispatch = jest.fn()
197
197
 
198
- moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set() } })
198
+ mapControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set() } })
199
199
  expect(dispatch).toHaveBeenCalledWith({
200
200
  type: 'TOGGLE_BUTTON_EXPANDED',
201
- payload: { id: 'moveControls', isExpanded: true }
201
+ payload: { id: 'mapControls', isExpanded: true }
202
202
  })
203
203
 
204
204
  dispatch.mockClear()
205
- moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set(['moveControls']) } })
205
+ mapControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set(['mapControls']) } })
206
206
  expect(dispatch).toHaveBeenCalledWith({
207
207
  type: 'TOGGLE_BUTTON_EXPANDED',
208
- payload: { id: 'moveControls', isExpanded: false }
208
+ payload: { id: 'mapControls', isExpanded: false }
209
209
  })
210
210
  })
211
211
 
212
212
  // --- MOVE CONTROLS ---
213
- it('covers all branches of moveControls control excludeWhen', () => {
214
- const control = defaultAppConfig.controls.find(c => c.id === 'moveControls')
215
- expect(control.excludeWhen({ appConfig: { enableMoveControls: false } })).toBe(true)
216
- expect(control.excludeWhen({ appConfig: { enableMoveControls: true } })).toBe(false)
213
+ it('covers all branches of mapControls control excludeWhen', () => {
214
+ const control = defaultAppConfig.controls.find(c => c.id === 'mapControls')
215
+ expect(control.excludeWhen({ appConfig: { enableMapControls: false } })).toBe(true)
216
+ expect(control.excludeWhen({ appConfig: { enableMapControls: true } })).toBe(false)
217
217
  })
218
218
 
219
219
  // --- SUPPLEMENTARY CONFIGS ---
@@ -16,7 +16,7 @@ const defaults = {
16
16
  containerHeight: '600px',
17
17
  deviceNotSupportedText: 'Your device is not supported. A map is available with a more up-to-date browser or device.',
18
18
  enableFullscreen: false,
19
- enableMoveControls: true,
19
+ enableMapControls: true,
20
20
  enableZoomControls: true,
21
21
  genericErrorText: 'There was a problem loading the map. Please try again later.',
22
22
  hasExitButton: false,
@@ -32,7 +32,7 @@ const defaults = {
32
32
  urlPosition: 'sync',
33
33
  maxMobileWidth: 640,
34
34
  minDesktopWidth: 835,
35
- moveControlsHintText: 'A "Move and zoom controls" button is also available, opening further controls for moving and zooming.',
35
+ mapControlsHintText: 'A "Move and zoom controls" button is also available, opening further controls for moving and zooming.',
36
36
  nudgePanDelta: 5,
37
37
  nudgeZoomDelta: 0.1,
38
38
  panDelta: 100,
@@ -145,14 +145,14 @@ export const EVENTS = {
145
145
  MAP_SET_STYLE: 'map:setstyle',
146
146
  /** @internal Set map size. Payload: { width, height } */
147
147
  MAP_SET_SIZE: 'map:setsize',
148
- /** @internal Set the accessible features list. Payload: { items: { id: string, label: string }[] } */
149
- MAP_SET_FEATURES: 'map:setfeatures',
150
- /** @internal Suppress/restore the accessible features list. Payload: { suppressed: boolean } */
151
- MAP_SET_FEATURES_SUPPRESSED: 'map:setfeaturessuppressed',
152
- /** @internal Set the active feature in the accessible features list. Payload: { id: string | null } */
153
- MAP_SET_ACTIVE_FEATURE: 'map:setactivefeature',
154
- /** @internal Select the active listbox feature as the real selection (Enter key). Payload: none */
155
- MAP_SELECT_FEATURE: 'map:selectfeature',
148
+ /** @internal Set the accessible spatial list's items. Payload: { items: { id: string, label: string }[], multiselectable: boolean, label?: string } */
149
+ MAP_SET_SPATIAL_LIST: 'map:setspatiallist',
150
+ /** @internal Suppress/restore the accessible spatial list. Payload: { suppressed: boolean } */
151
+ MAP_SET_SPATIAL_LIST_SUPPRESSED: 'map:setspatiallistsuppressed',
152
+ /** @internal Set the active item in the accessible spatial list (roving tabindex position). Payload: { id: string | null } */
153
+ MAP_SET_ACTIVE_ITEM: 'map:setactiveitem',
154
+ /** @internal Select the active spatial list item as the real selection (Enter key). Payload: none */
155
+ MAP_SELECT_ITEM: 'map:selectitem',
156
156
  /** @internal Set pixel ratio. Payload: pixelRatio */
157
157
  MAP_SET_PIXEL_RATIO: 'map:setpixelratio',
158
158
  /** @internal Fit the map to a bounding box. Payload: [west, south, east, north] */
@@ -29,9 +29,9 @@
29
29
  @use '../App/components/Hints/Hints.module';
30
30
  @use '../App/components/CrossHair/CrossHair.module';
31
31
  @use '../App/components/Markers/Markers.module';
32
- @use '../App/components/Viewport/Features.module';
32
+ @use '../App/components/Viewport/SpatialList.module';
33
33
  @use '../App/components/Actions/Actions.module';
34
- @use '../App/components/MoveControls/MoveControls.module';
34
+ @use '../App/components/MapControls/MapControls.module';
35
35
 
36
36
  // Components that dont have a corresponding React Component
37
37
  @use 'components';
package/src/test-utils.js CHANGED
@@ -37,6 +37,14 @@ export const createMockRegistries = (overrides = {}) => {
37
37
  registerKeyboardShortcut: jest.fn(),
38
38
  setProviderSupportedShortcuts: jest.fn(),
39
39
  getKeyboardShortcuts: jest.fn(() => [])
40
+ },
41
+ spatialListRegistry: {
42
+ registerItemProvider: jest.fn(),
43
+ unregisterItemProvider: jest.fn(),
44
+ claimExclusive: jest.fn(),
45
+ releaseExclusive: jest.fn(),
46
+ notifyItemsChanged: jest.fn(),
47
+ clear: jest.fn()
40
48
  }
41
49
  }
42
50
  }
package/src/types.js CHANGED
@@ -765,16 +765,18 @@
765
765
  * @property {boolean} [enableFullscreen=false]
766
766
  * Whether a toggle fullscreen button is displayed.
767
767
  *
768
- * @property {boolean} [enableMoveControls=true]
769
- * Whether the move controls are displayed — a button that reveals directional pan,
770
- * zoom, and step-size buttons, providing a non-dragging alternative to panning
771
- * and zooming the map (WCAG 2.5.7). Unlike enableZoomControls, it remains visible
772
- * when the interface type is 'touch'. When enabled, enableZoomControls' buttons
773
- * are hidden to avoid duplicating zoom controls.
768
+ * @property {boolean} [enableMapControls=true]
769
+ * Whether the map controls are displayed — a button that reveals on-screen move,
770
+ * zoom, and precision buttons, plus a target point, typically used to place or
771
+ * select features on the map without a drag gesture. This gives a non-dragging way
772
+ * to operate the map, for anyone who can't perform a drag/pinch gesture (e.g.
773
+ * switch access users) and for voice interfaces such as Voice Control, which can
774
+ * trigger a button click but not a drag. When enabled, enableZoomControls' buttons
775
+ * are hidden to avoid duplication.
774
776
  *
775
777
  * @property {boolean} [enableZoomControls=true]
776
778
  * Whether zoom control buttons are displayed. Not displayed when the interface
777
- * type is 'touch', or when enableMoveControls is enabled.
779
+ * type is 'touch', or when enableMapControls is enabled.
778
780
  *
779
781
  * @property {[number, number, number, number]} [extent]
780
782
  * Initial extent [minX, minY, maxX, maxY]. Equivalent to bounds; use whichever matches your map provider's terminology.
@@ -806,8 +808,8 @@
806
808
  * @property {string} [mapLabel='Interactive map']
807
809
  * Accessible label for the map, announced by screen readers. Also prefixed onto mapHintText, so give each map on a page a distinct label.
808
810
  *
809
- * @property {string} [moveControlsHintText]
810
- * Visually hidden text describing the move controls button, appended to keyboardHintText when enableMoveControls is true.
811
+ * @property {string} [mapControlsHintText]
812
+ * Visually hidden text describing the map controls button, appended to keyboardHintText when enableMapControls is true.
811
813
  *
812
814
  * @property {MapProviderDescriptor} [mapProvider]
813
815
  * A factory function that returns a map provider instance (e.g. maplibreProvider()).
@@ -0,0 +1,29 @@
1
+ import { spatialNavigate } from './spatialNavigate.js'
2
+
3
+ /**
4
+ * Item-list-shaped counterpart to spatialNavigate's raw pixel-array contract: given a list of
5
+ * items each carrying screen coordinates, finds the id of whichever one is spatially nearest
6
+ * to the current item in the given direction. Used to move roving-tabindex focus in a listbox
7
+ * spatially (Alt+Arrow) rather than sequentially (plain Arrow/Home/End) — the same items list
8
+ * already built for sequential navigation works here unchanged, whether it holds map features
9
+ * or (in future) something else entirely, as long as each item carries x/y.
10
+ *
11
+ * Items missing x/y are skipped — they have no screen position to navigate by. If none of the
12
+ * items have coordinates, or the list is empty, currentId is returned unchanged.
13
+ *
14
+ * @param {Array<{id: string, x?: number, y?: number}>} items
15
+ * @param {string|null} currentId
16
+ * @param {'ArrowUp'|'ArrowDown'|'ArrowLeft'|'ArrowRight'} direction
17
+ * @returns {string|null} The id to move to — currentId unchanged if nothing qualifies.
18
+ */
19
+ export function findNearestItemInDirection (items, currentId, direction) {
20
+ const positioned = items.filter(item => item.x != null && item.y != null)
21
+ if (!positioned.length) {
22
+ return currentId
23
+ }
24
+ const pixels = positioned.map(item => [item.x, item.y])
25
+ const currentIndex = positioned.findIndex(item => item.id === currentId)
26
+ const start = currentIndex === -1 ? pixels[0] : pixels[currentIndex]
27
+ const nextIndex = spatialNavigate(start, pixels, direction)
28
+ return positioned[nextIndex]?.id ?? currentId
29
+ }
@@ -0,0 +1,67 @@
1
+ import { findNearestItemInDirection } from './findNearestItemInDirection.js'
2
+
3
+ describe('findNearestItemInDirection', () => {
4
+ const items = [
5
+ { id: 'center', x: 0, y: 0 },
6
+ { id: 'up', x: 0, y: -10 },
7
+ { id: 'down', x: 0, y: 10 },
8
+ { id: 'left', x: -10, y: 0 },
9
+ { id: 'right', x: 10, y: 0 }
10
+ ]
11
+
12
+ test.each([
13
+ ['ArrowUp', 'up'],
14
+ ['ArrowDown', 'down'],
15
+ ['ArrowLeft', 'left'],
16
+ ['ArrowRight', 'right']
17
+ ])('finds the nearest item to the %s of the current one', (direction, expectedId) => {
18
+ expect(findNearestItemInDirection(items, 'center', direction)).toBe(expectedId)
19
+ })
20
+
21
+ test('returns the current id unchanged when nothing qualifies in that direction', () => {
22
+ const single = [{ id: 'only', x: 0, y: 0 }]
23
+ expect(findNearestItemInDirection(single, 'only', 'ArrowUp')).toBe('only')
24
+ })
25
+
26
+ test('returns currentId unchanged when the item list is empty', () => {
27
+ expect(findNearestItemInDirection([], 'anything', 'ArrowUp')).toBe('anything')
28
+ })
29
+
30
+ test('skips items with no screen coordinates', () => {
31
+ const withUnpositioned = [
32
+ { id: 'center', x: 0, y: 0 },
33
+ { id: 'no-coords' },
34
+ { id: 'up', x: 0, y: -10 }
35
+ ]
36
+ expect(findNearestItemInDirection(withUnpositioned, 'center', 'ArrowUp')).toBe('up')
37
+ })
38
+
39
+ test('returns currentId unchanged when no item has coordinates at all', () => {
40
+ const noneWithCoords = [{ id: 'a' }, { id: 'b' }]
41
+ expect(findNearestItemInDirection(noneWithCoords, 'a', 'ArrowUp')).toBe('a')
42
+ })
43
+
44
+ test('treats an unresolvable currentId as starting from the first positioned item', () => {
45
+ expect(findNearestItemInDirection(items, 'not-in-list', 'ArrowRight')).toBe('right')
46
+ })
47
+ })
48
+
49
+ // spatialNavigate's own contract guarantees a valid index in practice (see its own tests), so
50
+ // this can't be reached through findNearestItemInDirection's real dependency — mocked here,
51
+ // matching labels.test.js's identical fallback test for navigateToNextLabel, purely to prove
52
+ // the defensive `?? currentId` itself degrades gracefully rather than crashing if that
53
+ // guarantee were ever weakened.
54
+ describe('findNearestItemInDirection — defensive fallback', () => {
55
+ afterEach(() => {
56
+ jest.dontMock('./spatialNavigate.js')
57
+ jest.resetModules()
58
+ })
59
+
60
+ test('falls back to currentId if spatialNavigate ever returns an out-of-range index', () => {
61
+ jest.resetModules()
62
+ jest.doMock('./spatialNavigate.js', () => ({ spatialNavigate: () => -1 }))
63
+ const { findNearestItemInDirection: fn } = require('./findNearestItemInDirection.js')
64
+ const items = [{ id: 'center', x: 0, y: 0 }, { id: 'up', x: 0, y: -10 }]
65
+ expect(fn(items, 'center', 'ArrowUp')).toBe('center')
66
+ })
67
+ })
@@ -0,0 +1,12 @@
1
+ // Alt+<key> keyup combos useKeyboardShortcuts.js binds globally (see keyboardMappings.js's
2
+ // `keyup` section) — a locally-focused context using the same combo must shadow it, or both
3
+ // fire. Single source of truth so a new global mapping is a visible prompt to check for that.
4
+ export const GLOBAL_ALT_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'])
5
+
6
+ // Call unconditionally from a local keyup handler while its scope is active — it only acts
7
+ // when the key is actually shadowed, so it's safe to call regardless of which key was pressed.
8
+ export const stopIfGlobalAltKey = (event) => {
9
+ if (event.altKey && GLOBAL_ALT_KEYS.has(event.key)) {
10
+ event.stopPropagation()
11
+ }
12
+ }
@@ -0,0 +1,27 @@
1
+ import { GLOBAL_ALT_KEYS, stopIfGlobalAltKey } from './globalAltShortcuts.js'
2
+
3
+ describe('GLOBAL_ALT_KEYS', () => {
4
+ test('lists the arrow keys and Enter — the combos keyboardMappings.js binds globally under Alt', () => {
5
+ expect([...GLOBAL_ALT_KEYS].sort()).toEqual(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Enter'].sort())
6
+ })
7
+ })
8
+
9
+ describe('stopIfGlobalAltKey', () => {
10
+ test.each(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'])('stops propagation for Alt+%s', (key) => {
11
+ const event = { altKey: true, key, stopPropagation: jest.fn() }
12
+ stopIfGlobalAltKey(event)
13
+ expect(event.stopPropagation).toHaveBeenCalled()
14
+ })
15
+
16
+ test('leaves the same keys alone without Alt held', () => {
17
+ const event = { altKey: false, key: 'ArrowUp', stopPropagation: jest.fn() }
18
+ stopIfGlobalAltKey(event)
19
+ expect(event.stopPropagation).not.toHaveBeenCalled()
20
+ })
21
+
22
+ test('leaves an Alt+<other key> alone — nothing global uses this combo', () => {
23
+ const event = { altKey: true, key: 'i', stopPropagation: jest.fn() }
24
+ stopIfGlobalAltKey(event)
25
+ expect(event.stopPropagation).not.toHaveBeenCalled()
26
+ })
27
+ })
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Given a starting screen pixel and a set of candidate pixels, finds the index of the
3
+ * candidate that's nearest to `start` in the given cardinal direction — the shared primitive
4
+ * behind every directional "move to the next spatially-adjacent thing" interaction in this
5
+ * codebase (map label navigation, draw/edit vertex navigation, and the map features listbox).
6
+ *
7
+ * Candidates are first filtered to a ~90° cone centred on the pressed direction (whichever
8
+ * axis dominates the offset decides up/down vs left/right), then the nearest of those by
9
+ * Euclidean distance wins. If nothing falls in that cone, `start`'s own index in `pixels` is
10
+ * returned (a no-op move) rather than jumping somewhere the user didn't ask for.
11
+ *
12
+ * Previously duplicated (with subtly different tie-breaking/equality logic) between
13
+ * providers/maplibre/src/utils/spatial.js and plugins/draw/src/utils/spatial.js —
14
+ * consolidated here as the one canonical implementation both now import.
15
+ *
16
+ * @param {[number, number]} start - Current pixel [x, y].
17
+ * @param {Array<[number, number]>} pixels - All candidate pixels, including `start` itself.
18
+ * @param {'ArrowUp'|'ArrowDown'|'ArrowLeft'|'ArrowRight'|undefined} direction - undefined matches every candidate (nearest overall, any direction).
19
+ * @returns {number} Index into `pixels` of the chosen candidate.
20
+ */
21
+ export const spatialNavigate = (start, pixels, direction) => {
22
+ const isSamePixel = (a, b) => a[0] === b[0] && a[1] === b[1]
23
+
24
+ const quadrant = pixels.filter((p) => {
25
+ const offsetX = Math.abs(p[0] - start[0])
26
+ const offsetY = Math.abs(p[1] - start[1])
27
+ let isQuadrant = false
28
+ if (direction === 'ArrowUp') {
29
+ isQuadrant = p[1] <= start[1] && offsetY >= offsetX
30
+ } else if (direction === 'ArrowDown') {
31
+ isQuadrant = p[1] > start[1] && offsetY >= offsetX
32
+ } else if (direction === 'ArrowLeft') {
33
+ isQuadrant = p[0] <= start[0] && offsetY < offsetX
34
+ } else if (direction === 'ArrowRight') {
35
+ isQuadrant = p[0] > start[0] && offsetY < offsetX
36
+ } else {
37
+ isQuadrant = true
38
+ }
39
+ return isQuadrant && !isSamePixel(p, start)
40
+ })
41
+
42
+ if (!quadrant.length) {
43
+ quadrant.push(start)
44
+ }
45
+
46
+ const distance = (p) => Math.hypot(start[0] - p[0], start[1] - p[1])
47
+ const closest = quadrant.reduce((best, p) => (distance(p) < distance(best) ? p : best), quadrant[0])
48
+ return pixels.findIndex((p) => isSamePixel(p, closest))
49
+ }
@@ -0,0 +1,42 @@
1
+ import { spatialNavigate } from './spatialNavigate.js'
2
+
3
+ describe('spatialNavigate', () => {
4
+ const start = [0, 0]
5
+ const pixels = [[0, 0], [0, -10], [0, 10], [-10, 0], [10, 0]]
6
+
7
+ test.each([
8
+ ['ArrowUp', 1],
9
+ ['ArrowDown', 2],
10
+ ['ArrowLeft', 3],
11
+ ['ArrowRight', 4]
12
+ ])('finds the nearest pixel for %s', (direction, expectedIndex) => {
13
+ expect(spatialNavigate(start, pixels, direction)).toBe(expectedIndex)
14
+ })
15
+
16
+ test('considers all pixels for an unrecognised direction', () => {
17
+ expect(spatialNavigate(start, pixels, 'Tab')).toBe(1)
18
+ })
19
+
20
+ test('considers all pixels when no direction is given (nearest overall)', () => {
21
+ expect(spatialNavigate(start, pixels, undefined)).toBe(1)
22
+ })
23
+
24
+ test('falls back to the start point when no pixel is in the quadrant', () => {
25
+ expect(spatialNavigate(start, [[0, 0]], 'ArrowUp')).toBe(0)
26
+ })
27
+
28
+ test('picks the closer of two candidates in the same direction', () => {
29
+ const pixels = [[0, 0], [10, 0], [2, 0]]
30
+ expect(spatialNavigate(start, pixels, 'ArrowRight')).toBe(2)
31
+ })
32
+
33
+ test('keeps the first-seen closer candidate rather than a later farther one', () => {
34
+ const pixels = [[0, 0], [2, 0], [10, 0]]
35
+ expect(spatialNavigate(start, pixels, 'ArrowRight')).toBe(1)
36
+ })
37
+
38
+ test('a diagonal candidate with dx > dy counts as ArrowRight, not ArrowUp/Down', () => {
39
+ const pixels = [[0, 0], [3, 1], [1, 0]]
40
+ expect(spatialNavigate(start, pixels, 'ArrowRight')).toBe(2)
41
+ })
42
+ })
@@ -1,49 +0,0 @@
1
- import React, { forwardRef } from 'react'
2
- import { useConfig } from '../../store/configContext.js'
3
-
4
- export const Features = forwardRef(({ activeFeatureId, tabbableId, selectedIds = [], multiselectable = false, items = [], onFocus, onBlur, onSelectItem }, ref) => {
5
- const { id } = useConfig()
6
- const hasItems = items.length > 0
7
- // Roving tabindex: exactly one option is a Tab stop at a time (the focused item while the
8
- // list has real focus, otherwise the resting entry position) — see useFeatureFocus.js.
9
- const currentId = activeFeatureId ?? tabbableId
10
- return (
11
- <ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> cannot host SVG marker elements
12
- id={`${id}-features`}
13
- ref={ref}
14
- role='listbox' // NOSONAR
15
- aria-hidden={hasItems ? undefined : true}
16
- aria-label='Map features'
17
- aria-describedby={`${id}-keyboard-desc`}
18
- aria-multiselectable={multiselectable || undefined}
19
- className='im-c-features'
20
- onFocus={onFocus}
21
- onBlur={onBlur}
22
- >
23
- {items.map(item => (
24
- <li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern.
25
- // tabIndex/onClick make each option a real, independently focusable and activatable
26
- // control — required so touch screen readers and voice control can reach and operate
27
- // it directly, not just app-managed keyboard-arrow navigation.
28
- key={item.id} id={`${id}-feature-${item.id}`} role='option' // NOSONAR
29
- data-id={item.id}
30
- tabIndex={item.id === currentId ? 0 : -1}
31
- aria-selected={selectedIds.includes(item.id)}
32
- // Positions the (visually clipped) option over its on-map feature — item.x/item.y are
33
- // screen coordinates from the interact plugin (see useMapItemList.js). Without this,
34
- // every option collapses to the CSS static-position default (~top-left of the
35
- // viewport), which is invisible to sighted users but misaligns coordinate-based AT
36
- // overlays, e.g. macOS Voice Control's "Show Numbers", which numbers items at their
37
- // real position on screen. Items without a resolved position (not yet supported for
38
- // dataset/polygon features) fall back to that same default.
39
- style={item.x != null ? { left: item.x, top: item.y } : undefined}
40
- onClick={() => onSelectItem?.(item.id)}
41
- >
42
- {item.label}
43
- </li>
44
- ))}
45
- </ul>
46
- )
47
- })
48
-
49
- Features.displayName = 'Features'