@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
@@ -1,6 +1,6 @@
1
1
  import React from 'react'
2
2
  import { render, screen, fireEvent } from '@testing-library/react'
3
- import { MoveControls } from './MoveControls.jsx'
3
+ import { MapControls } from './MapControls.jsx'
4
4
  import { useApp } from '../../store/appContext.js'
5
5
  import { useConfig } from '../../store/configContext.js'
6
6
  import { useMap } from '../../store/mapContext.js'
@@ -11,19 +11,19 @@ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
11
11
  jest.mock('../../store/mapContext.js', () => ({ useMap: jest.fn() }))
12
12
  jest.mock('../../store/serviceContext.js', () => ({ useService: jest.fn() }))
13
13
 
14
- describe('MoveControls', () => {
14
+ describe('MapControls', () => {
15
15
  let dispatch
16
16
  let mapProvider
17
17
  let announce
18
18
 
19
19
  // MapButton and Tooltip also read from useApp() (buttonRefs, interfaceType), so every
20
- // mockReturnValue needs to carry these alongside the fields MoveControls itself reads.
20
+ // mockReturnValue needs to carry these alongside the fields MapControls itself reads.
21
21
  const buildAppState = (overrides) => ({
22
22
  buttonRefs: { current: {} },
23
23
  interfaceType: 'mouse',
24
24
  breakpoint: 'desktop',
25
25
  dispatch,
26
- expandedButtons: new Set(['moveControls']),
26
+ expandedButtons: new Set(['mapControls']),
27
27
  nudgeStepSize: 'large',
28
28
  layoutRefs: { viewportRef: { current: { focus: jest.fn() } } },
29
29
  ...overrides
@@ -50,29 +50,29 @@ describe('MoveControls', () => {
50
50
  afterEach(() => jest.clearAllMocks())
51
51
 
52
52
  it('renders with the id matching the trigger button aria-controls value', () => {
53
- const { container } = render(<MoveControls />)
54
- expect(container.querySelector('#im-move-controls-content')).toBeInTheDocument()
53
+ const { container } = render(<MapControls />)
54
+ expect(container.querySelector('#im-map-controls-content')).toBeInTheDocument()
55
55
  })
56
56
 
57
- it('is not visually collapsed when moveControls is expanded', () => {
58
- const { container } = render(<MoveControls />)
59
- expect(container.querySelector('.im-c-move-controls--collapsed')).not.toBeInTheDocument()
57
+ it('is not visually collapsed when mapControls is expanded', () => {
58
+ const { container } = render(<MapControls />)
59
+ expect(container.querySelector('.im-c-map-controls--collapsed')).not.toBeInTheDocument()
60
60
  })
61
61
 
62
62
  it('moves focus to the first direction button when the control opens', () => {
63
- const { rerender } = render(<MoveControls />)
63
+ const { rerender } = render(<MapControls />)
64
64
  useApp.mockReturnValue(buildAppState({ expandedButtons: new Set() }))
65
- rerender(<MoveControls />)
65
+ rerender(<MapControls />)
66
66
 
67
- useApp.mockReturnValue(buildAppState({ expandedButtons: new Set(['moveControls']) }))
68
- rerender(<MoveControls />)
67
+ useApp.mockReturnValue(buildAppState({ expandedButtons: new Set(['mapControls']) }))
68
+ rerender(<MapControls />)
69
69
  expect(screen.getByRole('button', { name: 'Move up' })).toHaveFocus()
70
70
  })
71
71
 
72
72
  it('renders the directions group before the zoom group at every breakpoint', () => {
73
73
  ['mobile', 'tablet', 'desktop'].forEach(breakpoint => {
74
74
  useApp.mockReturnValue(buildAppState({ breakpoint }))
75
- const { container, unmount } = render(<MoveControls />)
75
+ const { container, unmount } = render(<MapControls />)
76
76
  const groups = container.querySelectorAll('[role="group"]')
77
77
  expect(groups[0]).toHaveAttribute('aria-label', 'Direction controls')
78
78
  expect(groups[1]).toHaveAttribute('aria-label', 'Zoom controls')
@@ -80,14 +80,14 @@ describe('MoveControls', () => {
80
80
  })
81
81
  })
82
82
 
83
- it('is collapsed when moveControls is not expanded', () => {
83
+ it('is collapsed when mapControls is not expanded', () => {
84
84
  useApp.mockReturnValue(buildAppState({ expandedButtons: new Set() }))
85
- const { container } = render(<MoveControls />)
86
- expect(container.querySelector('.im-c-move-controls--collapsed')).toBeInTheDocument()
85
+ const { container } = render(<MapControls />)
86
+ expect(container.querySelector('.im-c-map-controls--collapsed')).toBeInTheDocument()
87
87
  })
88
88
 
89
89
  it('labels direction buttons "Move" and pans by the large delta by default', () => {
90
- render(<MoveControls />)
90
+ render(<MapControls />)
91
91
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
92
92
  expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
93
93
  expect(announce).toHaveBeenCalledWith('Moved right')
@@ -95,7 +95,7 @@ describe('MoveControls', () => {
95
95
 
96
96
  it('labels direction buttons "Nudge" and pans by the small delta when nudgeStepSize is small', () => {
97
97
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
98
- render(<MoveControls />)
98
+ render(<MapControls />)
99
99
  fireEvent.click(screen.getByRole('button', { name: 'Nudge up' }))
100
100
  expect(mapProvider.panBy).toHaveBeenCalledWith([0, -5])
101
101
  expect(announce).toHaveBeenCalledWith('Nudged up')
@@ -103,7 +103,7 @@ describe('MoveControls', () => {
103
103
 
104
104
  it('routes direction clicks to mapProvider.activeMoveTarget instead of panning, when a plugin has claimed it', () => {
105
105
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
106
- render(<MoveControls />)
106
+ render(<MapControls />)
107
107
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
108
108
  expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(1, 0, true)
109
109
  expect(mapProvider.panBy).not.toHaveBeenCalled()
@@ -112,22 +112,22 @@ describe('MoveControls', () => {
112
112
 
113
113
  it('falls back to panning once activeMoveTarget is released', () => {
114
114
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
115
- const { rerender } = render(<MoveControls />)
115
+ const { rerender } = render(<MapControls />)
116
116
  mapProvider.activeMoveTarget = null
117
- rerender(<MoveControls />)
117
+ rerender(<MapControls />)
118
118
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
119
119
  expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
120
120
  })
121
121
 
122
122
  it('omits the target label from the announcement when activeMoveTarget has none', () => {
123
123
  mapProvider.activeMoveTarget = { move: jest.fn() }
124
- render(<MoveControls />)
124
+ render(<MapControls />)
125
125
  fireEvent.click(screen.getByRole('button', { name: 'Move up' }))
126
126
  expect(announce).toHaveBeenCalledWith('Moved up')
127
127
  })
128
128
 
129
129
  it('zooms in and out by the large delta by default and announces the action', () => {
130
- render(<MoveControls />)
130
+ render(<MapControls />)
131
131
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
132
132
  expect(mapProvider.zoomIn).toHaveBeenCalledWith(1)
133
133
  expect(announce).toHaveBeenCalledWith('Zoomed in')
@@ -139,26 +139,26 @@ describe('MoveControls', () => {
139
139
 
140
140
  it('zooms by the small delta when nudgeStepSize is small', () => {
141
141
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
142
- render(<MoveControls />)
142
+ render(<MapControls />)
143
143
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
144
144
  expect(mapProvider.zoomIn).toHaveBeenCalledWith(0.1)
145
145
  })
146
146
 
147
147
  it('disables the zoom in button at max zoom, and zoom out at min zoom', () => {
148
148
  useMap.mockReturnValue({ isAtMaxZoom: true, isAtMinZoom: false })
149
- const { rerender } = render(<MoveControls />)
149
+ const { rerender } = render(<MapControls />)
150
150
  expect(screen.getByRole('button', { name: 'Zoom in' })).toHaveAttribute('aria-disabled', 'true')
151
151
  expect(screen.getByRole('button', { name: 'Zoom out' })).not.toHaveAttribute('aria-disabled')
152
152
 
153
153
  useMap.mockReturnValue({ isAtMaxZoom: false, isAtMinZoom: true })
154
- rerender(<MoveControls />)
154
+ rerender(<MapControls />)
155
155
  expect(screen.getByRole('button', { name: 'Zoom in' })).not.toHaveAttribute('aria-disabled')
156
156
  expect(screen.getByRole('button', { name: 'Zoom out' })).toHaveAttribute('aria-disabled', 'true')
157
157
  })
158
158
 
159
159
  it('does not zoom when the relevant button is disabled at max/min zoom', () => {
160
160
  useMap.mockReturnValue({ isAtMaxZoom: true, isAtMinZoom: true })
161
- render(<MoveControls />)
161
+ render(<MapControls />)
162
162
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
163
163
  fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
164
164
  expect(mapProvider.zoomIn).not.toHaveBeenCalled()
@@ -177,7 +177,7 @@ describe('MoveControls', () => {
177
177
  it('returns focus to the viewport after panning on mouse/touch, so arrow-key shortcuts elsewhere are not left stranded on the D-pad button', () => {
178
178
  const appState = buildAppState({ interfaceType: 'mouse' })
179
179
  useApp.mockReturnValue(appState)
180
- render(<MoveControls />)
180
+ render(<MapControls />)
181
181
 
182
182
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
183
183
  expect(appState.layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
@@ -185,19 +185,19 @@ describe('MoveControls', () => {
185
185
 
186
186
  it('keeps focus on the button when driven by keyboard, so repeated Enter/Space presses do not require re-tabbing', () => {
187
187
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
188
- render(<MoveControls />)
188
+ render(<MapControls />)
189
189
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
190
190
  expect(rafSpy).not.toHaveBeenCalled()
191
191
  })
192
192
 
193
193
  it('also returns focus after zooming on mouse/touch, but not on keyboard', () => {
194
194
  useApp.mockReturnValue(buildAppState({ interfaceType: 'mouse' }))
195
- const { rerender } = render(<MoveControls />)
195
+ const { rerender } = render(<MapControls />)
196
196
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
197
197
  expect(rafSpy).toHaveBeenCalledTimes(1)
198
198
 
199
199
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
200
- rerender(<MoveControls />)
200
+ rerender(<MapControls />)
201
201
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
202
202
  expect(rafSpy).toHaveBeenCalledTimes(1)
203
203
  })
@@ -205,7 +205,7 @@ describe('MoveControls', () => {
205
205
  it('also returns focus after a vertex nudge via activeMoveTarget on mouse/touch', () => {
206
206
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
207
207
  useApp.mockReturnValue(buildAppState({ interfaceType: 'touch' }))
208
- render(<MoveControls />)
208
+ render(<MapControls />)
209
209
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
210
210
  expect(rafSpy).toHaveBeenCalledTimes(1)
211
211
  })
@@ -217,7 +217,7 @@ describe('MoveControls', () => {
217
217
  // arrow keys without first tabbing all the way back out to the map.
218
218
  it('pans the map on an arrow key, regardless of which button currently has focus', () => {
219
219
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
220
- render(<MoveControls />)
220
+ render(<MapControls />)
221
221
  // Focus a button unrelated to the direction being pressed, to prove this
222
222
  // isn't just reading the focused button's own handler.
223
223
  fireEvent.focus(screen.getByRole('button', { name: 'Zoom in' }))
@@ -229,7 +229,7 @@ describe('MoveControls', () => {
229
229
  it('routes the arrow key through activeMoveTarget when a plugin has claimed the control', () => {
230
230
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
231
231
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
232
- render(<MoveControls />)
232
+ render(<MapControls />)
233
233
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp' })
234
234
  expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, true)
235
235
  expect(mapProvider.panBy).not.toHaveBeenCalled()
@@ -237,14 +237,14 @@ describe('MoveControls', () => {
237
237
 
238
238
  it('ignores non-arrow keys, leaving default behaviour (e.g. Enter/Space activating the focused button) untouched', () => {
239
239
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
240
- render(<MoveControls />)
240
+ render(<MapControls />)
241
241
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'Enter' })
242
242
  expect(mapProvider.panBy).not.toHaveBeenCalled()
243
243
  })
244
244
 
245
245
  it('shift+arrow overrides the Precision toggle to the small step, matching the map\'s own native keyboard shortcuts', () => {
246
246
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
247
- render(<MoveControls />)
247
+ render(<MapControls />)
248
248
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight', shiftKey: true })
249
249
  expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
250
250
  expect(announce).toHaveBeenCalledWith('Nudged right')
@@ -252,7 +252,7 @@ describe('MoveControls', () => {
252
252
 
253
253
  it('shift+arrow still resolves to the small step when Precision is already on (idempotent, not a toggle-relative flip)', () => {
254
254
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'small' }))
255
- render(<MoveControls />)
255
+ render(<MapControls />)
256
256
  fireEvent.keyDown(screen.getByRole('button', { name: 'Nudge right' }), { key: 'ArrowRight', shiftKey: true })
257
257
  expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
258
258
  expect(announce).toHaveBeenCalledWith('Nudged right')
@@ -261,14 +261,14 @@ describe('MoveControls', () => {
261
261
  it('shift+arrow overrides activeMoveTarget.move to the small step too', () => {
262
262
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
263
263
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
264
- render(<MoveControls />)
264
+ render(<MapControls />)
265
265
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp', shiftKey: true })
266
266
  expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, false)
267
267
  })
268
268
 
269
269
  it('arrow key without shift still follows the Precision toggle as before', () => {
270
270
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
271
- render(<MoveControls />)
271
+ render(<MapControls />)
272
272
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight' })
273
273
  expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
274
274
  expect(announce).toHaveBeenCalledWith('Moved right')
@@ -276,11 +276,11 @@ describe('MoveControls', () => {
276
276
  })
277
277
 
278
278
  it('has a stable "Precision" label regardless of state', () => {
279
- const { rerender } = render(<MoveControls />)
279
+ const { rerender } = render(<MapControls />)
280
280
  expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
281
281
 
282
282
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
283
- rerender(<MoveControls />)
283
+ rerender(<MapControls />)
284
284
  expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
285
285
  })
286
286
 
@@ -294,29 +294,29 @@ describe('MoveControls', () => {
294
294
  return document.getElementById(button.getAttribute('aria-labelledby'))
295
295
  }
296
296
 
297
- const { rerender } = render(<MoveControls />)
297
+ const { rerender } = render(<MapControls />)
298
298
  let tooltip = getOwnTooltip()
299
299
  expect(tooltip.querySelector('[aria-hidden="true"]')).toHaveTextContent('(Off)')
300
300
  expect(tooltip).toHaveTextContent('Precision (Off)')
301
301
 
302
302
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
303
- rerender(<MoveControls />)
303
+ rerender(<MapControls />)
304
304
  tooltip = getOwnTooltip()
305
305
  expect(tooltip.querySelector('[aria-hidden="true"]')).toHaveTextContent('(On)')
306
306
  expect(tooltip).toHaveTextContent('Precision (On)')
307
307
  })
308
308
 
309
309
  it('reflects precision mode via aria-pressed, not via label changes', () => {
310
- const { rerender } = render(<MoveControls />)
310
+ const { rerender } = render(<MapControls />)
311
311
  expect(screen.getByRole('button', { name: 'Precision' })).toHaveAttribute('aria-pressed', 'false')
312
312
 
313
313
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
314
- rerender(<MoveControls />)
314
+ rerender(<MapControls />)
315
315
  expect(screen.getByRole('button', { name: 'Precision' })).toHaveAttribute('aria-pressed', 'true')
316
316
  })
317
317
 
318
318
  it('toggles precision on and announces it when currently in large-step mode', () => {
319
- render(<MoveControls />)
319
+ render(<MapControls />)
320
320
  fireEvent.click(screen.getByRole('button', { name: 'Precision' }))
321
321
  expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_NUDGE_STEP' })
322
322
  expect(announce).toHaveBeenCalledWith('Precision on')
@@ -324,7 +324,7 @@ describe('MoveControls', () => {
324
324
 
325
325
  it('toggles precision off and announces it when currently in small-step mode', () => {
326
326
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
327
- render(<MoveControls />)
327
+ render(<MapControls />)
328
328
  fireEvent.click(screen.getByRole('button', { name: 'Precision' }))
329
329
  expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_NUDGE_STEP' })
330
330
  expect(announce).toHaveBeenCalledWith('Precision off')
@@ -67,11 +67,11 @@ export const Markers = () => {
67
67
  const handleSetActive = ({ id: markerId }) => setActiveMarkerId(markerId)
68
68
  eventBus.on('interact:active', handleActive)
69
69
  eventBus.on('interact:selectionchange', handleSelectionChange)
70
- eventBus.on(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
70
+ eventBus.on(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
71
71
  return () => {
72
72
  eventBus.off('interact:active', handleActive)
73
73
  eventBus.off('interact:selectionchange', handleSelectionChange)
74
- eventBus.off(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
74
+ eventBus.off(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
75
75
  }
76
76
  }, [eventBus])
77
77
 
@@ -20,9 +20,9 @@ const SVG_SEL = 'svg'
20
20
  const makeEventBus = () => {
21
21
  const listeners = {}
22
22
  return {
23
- on: jest.fn((e, fn) => { listeners[e] = fn }),
23
+ on: jest.fn((eventName, fn) => { listeners[eventName] = fn }),
24
24
  off: jest.fn(),
25
- emit: (e, payload) => listeners[e]?.(payload)
25
+ emit: (eventName, payload) => listeners[eventName]?.(payload)
26
26
  }
27
27
  }
28
28
 
@@ -170,7 +170,7 @@ describe('Markers — selection', () => {
170
170
  })
171
171
 
172
172
  it('calls resolveActive when the marker is the active listbox item', () => {
173
- const SET_ACTIVE = 'map:setactivefeature'
173
+ const SET_ACTIVE = 'map:setactiveitem'
174
174
  const sr = makeSymbolRegistry({ resolveActive: jest.fn(() => '<circle class="active"/>') })
175
175
  const { eb } = setup({ markers: [makeMarker()], symbolRegistry: sr })
176
176
  act(() => eb.emit(SET_ACTIVE, { id: MARKER_ID }))
@@ -19,8 +19,8 @@ describe('SymbolMarker', () => {
19
19
  expect(container.querySelector('svg').getAttribute('id')).toBe('my-map-marker-marker-1')
20
20
  })
21
21
 
22
- // Decorative: the ancestor markers layer is aria-hidden, and Features (the accessible
23
- // features listbox) is the sole accessible surface for a marker's label — see Features.jsx.
22
+ // Decorative: the ancestor markers layer is aria-hidden, and SpatialList (the accessible
23
+ // spatial listbox) is the sole accessible surface for a marker's label — see SpatialList.jsx.
24
24
  it('is aria-hidden', () => {
25
25
  const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
26
26
  expect(container.querySelector('svg').getAttribute('aria-hidden')).toBe('true')
@@ -11,7 +11,7 @@ export const MapStatus = () => {
11
11
  <div // NOSONAR: div + status role is semantically correct here, <output> implies a calculation result
12
12
  ref={mapStatusRef}
13
13
  role='status'
14
- className='im-c-viewport__status'
14
+ className='im-u-visually-hidden'
15
15
  />
16
16
  )
17
17
  }
@@ -0,0 +1,47 @@
1
+ import React, { forwardRef } from 'react'
2
+ import { useConfig } from '../../store/configContext.js'
3
+
4
+ // Accessible spatial list: a listbox whose items are positioned to match wherever they are on
5
+ // the map, navigable both sequentially (Arrow/Home/End) and spatially (Alt+Arrow — see
6
+ // useSpatialListFocus.js). Content is whatever the current spatialListRegistry provider
7
+ // contributes; `label` names it for aria-label accordingly.
8
+ export const SpatialList = forwardRef(({ activeItemId, tabbableId, selectedIds = [], multiselectable = false, items = [], label = 'Map features', focusable = true, onFocus, onBlur, onSelectItem }, ref) => {
9
+ const { id } = useConfig()
10
+ const hasItems = items.length > 0
11
+ // Roving tabindex: exactly one option is a Tab stop at a time — see useSpatialListFocus.js.
12
+ const currentId = activeItemId ?? tabbableId
13
+ return (
14
+ <ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> cannot host SVG marker elements
15
+ id={`${id}-spatial-list`}
16
+ ref={ref}
17
+ role='listbox' // NOSONAR
18
+ aria-hidden={hasItems ? undefined : true}
19
+ aria-label={label}
20
+ aria-describedby={`${id}-keyboard-desc`}
21
+ aria-multiselectable={multiselectable || undefined}
22
+ className='im-c-spatial-list'
23
+ onFocus={onFocus}
24
+ onBlur={onBlur}
25
+ >
26
+ {items.map(item => (
27
+ <li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern.
28
+ // tabIndex/onClick let voice control and screen readers reach and activate each option
29
+ // directly. focusable: false (draw's vertex/midpoint items) keeps every item out of
30
+ // the Tab order — that population already has a full keyboard path via the map itself.
31
+ key={item.id} id={`${id}-spatial-list-item-${item.id}`} role='option' // NOSONAR
32
+ data-id={item.id}
33
+ tabIndex={focusable && item.id === currentId ? 0 : -1}
34
+ aria-selected={selectedIds.includes(item.id)}
35
+ // Positions the (visually clipped) option over its on-map feature, so AT overlays like
36
+ // Voice Control's "Show Numbers" number it at the right screen position.
37
+ style={item.x != null ? { left: item.x, top: item.y } : undefined}
38
+ onClick={() => onSelectItem?.(item.id)}
39
+ >
40
+ {item.label}
41
+ </li>
42
+ ))}
43
+ </ul>
44
+ )
45
+ })
46
+
47
+ SpatialList.displayName = 'SpatialList'
@@ -1,10 +1,10 @@
1
1
  @use '../../../scss/tools/index' as tools;
2
2
 
3
3
  // ===================================================
4
- // Component: Features
4
+ // Component: SpatialList
5
5
  // ===================================================
6
6
 
7
- .im-c-features {
7
+ .im-c-spatial-list {
8
8
  position: absolute;
9
9
  inset: 0;
10
10
  pointer-events: none;
@@ -24,7 +24,7 @@
24
24
  clip-path: inset(50%);
25
25
  white-space: nowrap;
26
26
  // Roving tabindex moves real focus (and click activation) onto individual options, so they
27
- // need pointer events re-enabled — overridden off .im-c-features's pointer-events: none,
27
+ // need pointer events re-enabled — overridden off .im-c-spatial-list's pointer-events: none,
28
28
  // which otherwise lets mouse interaction pass through to the map everywhere else.
29
29
  pointer-events: auto;
30
30
  }