@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,7 +1,7 @@
1
1
  import React, { useRef } from 'react'
2
2
  import { render, fireEvent, act } from '@testing-library/react'
3
- import { Features } from './Features.jsx'
4
- import { useFeatureFocus } from '../../hooks/useFeatureFocus.js'
3
+ import { SpatialList } from './SpatialList.jsx'
4
+ import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
5
5
  import { useConfig } from '../../store/configContext.js'
6
6
 
7
7
  jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
@@ -19,145 +19,162 @@ beforeEach(() => {
19
19
  useConfig.mockReturnValue({ id: APP_ID })
20
20
  })
21
21
 
22
- // ─── Features — rendering ─────────────────────────────────────────────────────
22
+ // ─── SpatialList — rendering ─────────────────────────────────────────────────────
23
23
 
24
- describe('Features — rendering', () => {
24
+ describe('SpatialList — rendering', () => {
25
25
  it('renders a listbox with the correct id', () => {
26
- const { container } = render(<Features />)
27
- expect(container.querySelector(`#${APP_ID}-features`)).toBeTruthy()
26
+ const { container } = render(<SpatialList />)
27
+ expect(container.querySelector(`#${APP_ID}-spatial-list`)).toBeTruthy()
28
28
  expect(container.querySelector(LISTBOX)).toBeTruthy() // NOSONAR
29
29
  })
30
30
 
31
31
  it('renders no options when items is empty', () => {
32
- const { container } = render(<Features />)
32
+ const { container } = render(<SpatialList />)
33
33
  expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) // NOSONAR
34
34
  })
35
35
 
36
+ it('defaults aria-label to "Map features" when no label prop is given', () => {
37
+ const { container } = render(<SpatialList />)
38
+ expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Map features')
39
+ })
40
+
41
+ it('uses whatever label prop is given, e.g. for draw\'s edit-mode vertices', () => {
42
+ const { container } = render(<SpatialList label='Shape points' />)
43
+ expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Shape points')
44
+ })
45
+
36
46
  it('renders one option per item with correct id, data-id and label', () => {
37
- const { container } = render(<Features items={ITEMS} />)
47
+ const { container } = render(<SpatialList items={ITEMS} />)
38
48
  const options = container.querySelectorAll(OPTION)
39
49
  expect(options).toHaveLength(2)
40
- expect(options[0].getAttribute('id')).toBe(`${APP_ID}-feature-f1`)
50
+ expect(options[0].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f1`)
41
51
  expect(options[0].dataset.id).toBe('f1')
42
52
  expect(options[0].textContent).toBe('Feature One')
43
- expect(options[1].getAttribute('id')).toBe(`${APP_ID}-feature-f2`)
53
+ expect(options[1].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f2`)
44
54
  expect(options[1].dataset.id).toBe('f2')
45
55
  expect(options[1].textContent).toBe('Feature Two')
46
56
  })
47
57
 
48
58
  it('positions an option at its x/y when provided, for coordinate-based AT overlays (e.g. Voice Control)', () => {
49
59
  const itemsWithPosition = [{ id: 'f1', label: 'Feature One', x: 120, y: 340 }]
50
- const { container } = render(<Features items={itemsWithPosition} />)
60
+ const { container } = render(<SpatialList items={itemsWithPosition} />)
51
61
  const option = container.querySelector(OPTION)
52
62
  expect(option.style.left).toBe('120px')
53
63
  expect(option.style.top).toBe('340px')
54
64
  })
55
65
 
56
66
  it('does not set left/top when an item has no x/y', () => {
57
- const { container } = render(<Features items={ITEMS} />)
67
+ const { container } = render(<SpatialList items={ITEMS} />)
58
68
  const option = container.querySelector(OPTION)
59
69
  expect(option.style.left).toBe('')
60
70
  expect(option.style.top).toBe('')
61
71
  })
62
72
 
63
73
  it('sets aria-selected on items present in selectedIds', () => {
64
- const { container } = render(<Features items={ITEMS} selectedIds={['f1']} />)
74
+ const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1']} />)
65
75
  const options = container.querySelectorAll(OPTION)
66
76
  expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
67
77
  expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
68
78
  })
69
79
 
70
80
  it('sets aria-selected on multiple items when selectedIds has multiple entries', () => {
71
- const { container } = render(<Features items={ITEMS} selectedIds={['f1', 'f2']} />)
81
+ const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1', 'f2']} />)
72
82
  const options = container.querySelectorAll(OPTION)
73
83
  expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
74
84
  expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true')
75
85
  })
76
86
 
77
- it('does not set aria-selected from activeFeatureId alone', () => {
78
- const { container } = render(<Features items={ITEMS} activeFeatureId='f1' />)
87
+ it('does not set aria-selected from activeItemId alone', () => {
88
+ const { container } = render(<SpatialList items={ITEMS} activeItemId='f1' />)
79
89
  const options = container.querySelectorAll(OPTION)
80
90
  expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
81
91
  expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
82
92
  })
83
93
 
84
94
  it('gives the active item tabIndex 0 and every other item tabIndex -1 (roving tabindex)', () => {
85
- const { container } = render(<Features items={ITEMS} activeFeatureId='f2' />)
95
+ const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' />)
86
96
  const options = container.querySelectorAll(OPTION)
87
97
  expect(options[0].getAttribute('tabIndex')).toBe('-1')
88
98
  expect(options[1].getAttribute('tabIndex')).toBe('0')
89
99
  })
90
100
 
91
- it('falls back to tabbableId for roving tabindex when activeFeatureId is absent', () => {
92
- const { container } = render(<Features items={ITEMS} tabbableId='f1' />)
101
+ it('falls back to tabbableId for roving tabindex when activeItemId is absent', () => {
102
+ const { container } = render(<SpatialList items={ITEMS} tabbableId='f1' />)
93
103
  const options = container.querySelectorAll(OPTION)
94
104
  expect(options[0].getAttribute('tabIndex')).toBe('0')
95
105
  expect(options[1].getAttribute('tabIndex')).toBe('-1')
96
106
  })
97
107
 
108
+ it('gives every item tabIndex -1 when focusable is false, even the active one — never a real Tab stop', () => {
109
+ const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' focusable={false} />)
110
+ const options = container.querySelectorAll(OPTION)
111
+ expect(options[0].getAttribute('tabIndex')).toBe('-1')
112
+ expect(options[1].getAttribute('tabIndex')).toBe('-1')
113
+ })
114
+
98
115
  it('sets aria-multiselectable when multiselectable is true', () => {
99
- const { container } = render(<Features items={ITEMS} multiselectable />)
116
+ const { container } = render(<SpatialList items={ITEMS} multiselectable />)
100
117
  expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBe('true') // NOSONAR
101
118
  })
102
119
 
103
120
  it('omits aria-multiselectable when multiselectable is false', () => {
104
- const { container } = render(<Features items={ITEMS} />)
121
+ const { container } = render(<SpatialList items={ITEMS} />)
105
122
  expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBeNull() // NOSONAR
106
123
  })
107
124
 
108
125
  it('is not aria-hidden when items are present', () => {
109
- const { container } = render(<Features items={ITEMS} />)
126
+ const { container } = render(<SpatialList items={ITEMS} />)
110
127
  const ul = container.querySelector(LISTBOX) // NOSONAR
111
128
  expect(ul.getAttribute('aria-hidden')).toBeNull()
112
129
  })
113
130
 
114
131
  it('is aria-hidden and has no options when items is empty', () => {
115
- const { container } = render(<Features />)
132
+ const { container } = render(<SpatialList />)
116
133
  const ul = container.querySelector(LISTBOX) // NOSONAR
117
134
  expect(ul.getAttribute('aria-hidden')).toBe('true')
118
135
  expect(container.querySelectorAll(OPTION)).toHaveLength(0) // NOSONAR
119
136
  })
120
137
 
121
138
  it('sets aria-describedby to the shared hints container id', () => {
122
- const { container } = render(<Features />)
139
+ const { container } = render(<SpatialList />)
123
140
  expect(container.querySelector(LISTBOX).getAttribute('aria-describedby')).toBe(`${APP_ID}-keyboard-desc`) // NOSONAR
124
141
  })
125
142
  })
126
143
 
127
- // ─── Features — interactions ──────────────────────────────────────────────────
144
+ // ─── SpatialList — interactions ──────────────────────────────────────────────────
128
145
 
129
- describe('Features — interactions', () => {
146
+ describe('SpatialList — interactions', () => {
130
147
  it('calls onFocus when the listbox receives focus', () => {
131
148
  const onFocus = jest.fn()
132
- const { container } = render(<Features onFocus={onFocus} />)
149
+ const { container } = render(<SpatialList onFocus={onFocus} />)
133
150
  fireEvent.focus(container.querySelector(LISTBOX)) // NOSONAR
134
151
  expect(onFocus).toHaveBeenCalled()
135
152
  })
136
153
 
137
154
  it('calls onBlur when the listbox loses focus', () => {
138
155
  const onBlur = jest.fn()
139
- const { container } = render(<Features onBlur={onBlur} />)
156
+ const { container } = render(<SpatialList onBlur={onBlur} />)
140
157
  fireEvent.blur(container.querySelector(LISTBOX)) // NOSONAR
141
158
  expect(onBlur).toHaveBeenCalled()
142
159
  })
143
160
 
144
161
  it('calls onSelectItem with the clicked item id', () => {
145
162
  const onSelectItem = jest.fn()
146
- const { container } = render(<Features items={ITEMS} onSelectItem={onSelectItem} />)
163
+ const { container } = render(<SpatialList items={ITEMS} onSelectItem={onSelectItem} />)
147
164
  fireEvent.click(container.querySelectorAll(OPTION)[1]) // NOSONAR
148
165
  expect(onSelectItem).toHaveBeenCalledWith('f2')
149
166
  })
150
167
 
151
168
  it('does not throw on click when onSelectItem is not provided', () => {
152
- const { container } = render(<Features items={ITEMS} />)
169
+ const { container } = render(<SpatialList items={ITEMS} />)
153
170
  expect(() => fireEvent.click(container.querySelectorAll(OPTION)[0])).not.toThrow() // NOSONAR
154
171
  })
155
172
  })
156
173
 
157
- // ─── Features + useFeatureFocus — roving tabindex wired together for real ────
174
+ // ─── SpatialList + useSpatialListFocus — roving tabindex wired together for real ────
158
175
  //
159
- // The tests above exercise Features in isolation (mocked onFocus/onBlur), and
160
- // useFeatureFocus.test.js exercises the hook in isolation (onFocus/onBlur called
176
+ // The tests above exercise SpatialList in isolation (mocked onFocus/onBlur), and
177
+ // useSpatialListFocus.test.js exercises the hook in isolation (onFocus/onBlur called
161
178
  // directly, not via real DOM events). Neither can catch a bug where moving real focus
162
179
  // between sibling options — which roving tabindex does on every arrow key — fires a
163
180
  // genuine native focusin that bubbles up and re-triggers React's onFocus on the <ul>.
@@ -165,19 +182,19 @@ describe('Features — interactions', () => {
165
182
 
166
183
  const RovingHarness = ({ eventBus }) => {
167
184
  const viewportRef = useRef(document.createElement('div'))
168
- const featuresRef = useRef(null)
169
- const { activeFeatureId, tabbableId, selectedIds, onFocus, onBlur, selectItem } = useFeatureFocus({
170
- viewportRef, featuresRef, items: ITEMS, eventBus, hints: { subscribe: () => () => {}, dismiss: () => {} }
185
+ const spatialListRef = useRef(null)
186
+ const { activeItemId, tabbableId, selectedIds, onFocus, onBlur, selectItem } = useSpatialListFocus({
187
+ viewportRef, spatialListRef, items: ITEMS, eventBus, hints: { subscribe: () => () => {}, dismiss: () => {} }
171
188
  })
172
189
  return (
173
- <Features
174
- ref={featuresRef} activeFeatureId={activeFeatureId} tabbableId={tabbableId} selectedIds={selectedIds}
190
+ <SpatialList
191
+ ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds}
175
192
  items={ITEMS} onFocus={onFocus} onBlur={onBlur} onSelectItem={selectItem}
176
193
  />
177
194
  )
178
195
  }
179
196
 
180
- describe('Features + useFeatureFocus — roving tabindex real focus events', () => {
197
+ describe('SpatialList + useSpatialListFocus — roving tabindex real focus events', () => {
181
198
  it('emits map:setactivefeature exactly once when arrowing to the next option', () => {
182
199
  const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
183
200
  const { container } = render(<RovingHarness eventBus={eb} />)
@@ -185,7 +202,7 @@ describe('Features + useFeatureFocus — roving tabindex real focus events', ()
185
202
  eb.emit.mockClear()
186
203
  fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
187
204
  expect(eb.emit).toHaveBeenCalledTimes(1)
188
- expect(eb.emit).toHaveBeenCalledWith('map:setactivefeature', { id: 'f2' })
205
+ expect(eb.emit).toHaveBeenCalledWith('map:setactiveitem', { id: 'f2' })
189
206
  })
190
207
 
191
208
  it('moves real DOM focus to the next option on ArrowDown', () => {
@@ -197,7 +214,7 @@ describe('Features + useFeatureFocus — roving tabindex real focus events', ()
197
214
  })
198
215
 
199
216
  // A real pub/sub bus, with a minimal fake "interact plugin" listening on it — mirroring what
200
- // useMapItemList.js actually does: track the last map:setactivefeature id, and on
217
+ // useSpatialList.js actually does: track the last map:setactivefeature id, and on
201
218
  // map:selectfeature, mark THAT id selected and fire interact:selectionchange back. This is
202
219
  // the closest reproduction of the real reported bug: Enter only ever selecting the first item
203
220
  // because every arrow move was clobbering the active id back to a stale value.
@@ -209,8 +226,8 @@ describe('Features + useFeatureFocus — roving tabindex real focus events', ()
209
226
  off: (event, fn) => { listeners[event] = (listeners[event] ?? []).filter(f => f !== fn) },
210
227
  emit: (event, payload) => { (listeners[event] ?? []).forEach(fn => fn(payload)) }
211
228
  }
212
- bus.on('map:setactivefeature', ({ id }) => { lastActiveId = id })
213
- bus.on('map:selectfeature', () => {
229
+ bus.on('map:setactiveitem', ({ id }) => { lastActiveId = id })
230
+ bus.on('map:selectitem', () => {
214
231
  bus.emit('interact:selectionchange', { selectedMarkers: lastActiveId ? [lastActiveId] : [] })
215
232
  })
216
233
  return bus
@@ -1,7 +1,8 @@
1
1
  import React, { useRef, useEffect } from 'react'
2
- import { useFeatureFocus } from '../../hooks/useFeatureFocus.js'
3
- import { useFeatureItems } from '../../hooks/useFeatureItems.js'
2
+ import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
3
+ import { useSpatialListItems } from '../../hooks/useSpatialListItems.js'
4
4
  import { EVENTS as events } from '../../../config/events.js'
5
+ import { scaleFactor } from '../../../config/appConfig.js'
5
6
  import { useConfig } from '../../store/configContext.js'
6
7
  import { useApp } from '../../store/appContext.js'
7
8
  import { useMap } from '../../store/mapContext.js'
@@ -12,7 +13,7 @@ import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts.js'
12
13
  import { useMapEvents } from '../../hooks/useMapEvents.js'
13
14
  import { MapStatus } from './MapStatus.jsx'
14
15
  import { CrossHair } from '../CrossHair/CrossHair'
15
- import { Features } from './Features'
16
+ import { SpatialList } from './SpatialList'
16
17
  import { Markers } from '../Markers/Markers'
17
18
 
18
19
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
@@ -20,14 +21,23 @@ import { Markers } from '../Markers/Markers'
20
21
  export const Viewport = () => {
21
22
  const { id, mapProvider, mapLabel, keyboardHintText, focusOnMount } = useConfig()
22
23
  const { mode, previousMode, layoutRefs, safeZoneInset, dispatch } = useApp()
23
- const { mapSize } = useMap()
24
+ const { mapSize, isMapReady } = useMap()
24
25
  const { eventBus, hints } = useService()
25
26
 
26
27
  const mapContainerRef = useRef(null)
27
- const featuresRef = useRef(null)
28
+ const spatialListRef = useRef(null)
28
29
 
29
- const { items: featureItems, multiselectable } = useFeatureItems(eventBus)
30
- const { activeFeatureId, tabbableId, selectedIds, onFocus: handleFeaturesFocus, onBlur: handleFeaturesBlur, selectItem } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems, eventBus, hints })
30
+ const { items: spatialListItems, multiselectable, label: spatialListLabel, focusable: spatialListFocusable } = useSpatialListItems(eventBus)
31
+ // Same reference point as Alt+Enter's highlightLabelAtCenter lets the listbox's entry
32
+ // fallback prefer whichever item is nearest the middle of the view. Guarded on isMapReady:
33
+ // the map engine doesn't exist yet on Viewport's first render, so mapToScreen()/getCenter()
34
+ // would throw; null is a safe, already-handled value (falls back to the first item).
35
+ let centerScreenPoint = null
36
+ if (isMapReady) {
37
+ const centerPixel = mapProvider.mapToScreen(mapProvider.getCenter())
38
+ centerScreenPoint = { x: centerPixel.x * scaleFactor[mapSize], y: centerPixel.y * scaleFactor[mapSize] }
39
+ }
40
+ const { activeItemId, tabbableId, selectedIds, onFocus: handleSpatialListFocus, onBlur: handleSpatialListBlur, selectItem } = useSpatialListFocus({ viewportRef: layoutRefs.viewportRef, spatialListRef, items: spatialListItems, eventBus, hints, centerScreenPoint })
31
41
 
32
42
  useEffect(() => {
33
43
  const handler = () => dispatch({ type: 'SET_LISTBOX_ACTIVE' })
@@ -35,8 +45,8 @@ export const Viewport = () => {
35
45
  return () => eventBus.off('interact:listboxcapable', handler)
36
46
  }, [eventBus])
37
47
 
38
- const onFeaturesFocus = () => { handleFeaturesFocus(); hints.show(keyboardHintText, { duration: 0 }) }
39
- const onFeaturesBlur = () => { handleFeaturesBlur(); hints.dismiss() }
48
+ const onSpatialListFocus = () => { handleSpatialListFocus(); hints.show(keyboardHintText, { duration: 0 }) }
49
+ const onSpatialListBlur = () => { handleSpatialListBlur(); hints.dismiss() }
40
50
 
41
51
  useKeyboardShortcuts(layoutRefs.viewportRef)
42
52
 
@@ -94,7 +104,7 @@ export const Viewport = () => {
94
104
  <Markers />
95
105
  </div>
96
106
  </div>
97
- <Features ref={featuresRef} activeFeatureId={activeFeatureId} tabbableId={tabbableId} selectedIds={selectedIds} multiselectable={multiselectable} items={featureItems} onFocus={onFeaturesFocus} onBlur={onFeaturesBlur} onSelectItem={selectItem} />
107
+ <SpatialList ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds} multiselectable={multiselectable} items={spatialListItems} label={spatialListLabel} focusable={spatialListFocusable} onFocus={onSpatialListFocus} onBlur={onSpatialListBlur} onSelectItem={selectItem} />
98
108
  </>
99
109
  )
100
110
  }
@@ -21,7 +21,13 @@ jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid
21
21
 
22
22
  const KEYBOARD_HINT_TEXT = 'Test keyboad hint text'
23
23
 
24
- const mockMapProvider = { initMap: jest.fn(), updateMap: jest.fn(), clearHighlightedLabel: jest.fn() }
24
+ const mockMapProvider = {
25
+ initMap: jest.fn(),
26
+ updateMap: jest.fn(),
27
+ clearHighlightedLabel: jest.fn(),
28
+ getCenter: jest.fn(() => [0, 0]),
29
+ mapToScreen: jest.fn(() => ({ x: 100, y: 100 }))
30
+ }
25
31
 
26
32
  function setupHookMocks (mainEl, viewportEl) {
27
33
  useConfig.mockReturnValue({
@@ -38,7 +44,7 @@ function setupHookMocks (mainEl, viewportEl) {
38
44
  safeZoneInset: {},
39
45
  dispatch: jest.fn()
40
46
  })
41
- useMap.mockReturnValue({ mapSize: 'medium', dispatch: jest.fn() })
47
+ useMap.mockReturnValue({ mapSize: 'medium', isMapReady: true, dispatch: jest.fn() })
42
48
  useService.mockReturnValue({
43
49
  announce: jest.fn(),
44
50
  hints: { show: jest.fn(), dismiss: jest.fn(), subscribe: jest.fn(() => jest.fn()) },
@@ -89,6 +95,16 @@ describe('Viewport rendering', () => {
89
95
  expect(markers).toBeInTheDocument()
90
96
  })
91
97
 
98
+ // Regression: Viewport (and MapController, which kicks off map creation) render before the
99
+ // underlying map engine actually exists — mapProvider.getCenter()/mapToScreen() throw until
100
+ // then, so the spatial listbox's "nearest to center" entry point must not call them yet.
101
+ it('does not throw, and skips mapProvider.getCenter/mapToScreen, before the map is ready', () => {
102
+ useMap.mockReturnValue({ mapSize: 'medium', isMapReady: false, dispatch: jest.fn() })
103
+ expect(() => renderViewport()).not.toThrow()
104
+ expect(mockMapProvider.getCenter).not.toHaveBeenCalled()
105
+ expect(mockMapProvider.mapToScreen).not.toHaveBeenCalled()
106
+ })
107
+
92
108
  it('renders viewport with correct id and class based on mapSize', () => {
93
109
  const { viewport } = renderViewport()
94
110
  expect(viewport.id).toBe('test-map-viewport')
@@ -36,13 +36,13 @@ const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition
36
36
  // tree while invisible. An earlier version kept it ax-tree-present here so Voice Control
37
37
  // could re-summon it by name, but that made "Show Names" put a "Target" label over
38
38
  // nothing whenever it was genuinely hidden, which is more confusing than helpful. Voice
39
- // Control's real route back is opening MoveControls (a normal, always-visible, always-named
40
- // button) — see isMoveControlsOpen below — so the crosshair itself doesn't need to stay
41
- // discoverable while it's not actually there. Blocked outright while MoveControls is open:
39
+ // Control's real route back is opening MapControls (a normal, always-visible, always-named
40
+ // button) — see isMapControlsOpen below — so the crosshair itself doesn't need to stay
41
+ // discoverable while it's not actually there. Blocked outright while MapControls is open:
42
42
  // that panel is a deliberate, explicit "I can't drag/pinch this map" signal from the user,
43
43
  // which should win over whatever mouse-vs-touch/keyboard inference would otherwise decide.
44
44
  crossHair.hide = () => {
45
- if (crossHair.isMoveControlsOpen) {
45
+ if (crossHair.isMapControlsOpen) {
46
46
  return
47
47
  }
48
48
  el.style.display = 'none'
@@ -124,7 +124,7 @@ export const useCrossHair = () => {
124
124
  // read the stale value and incorrectly block itself. Setting it directly in the render body
125
125
  // (matches DrawInit.jsx's shouldShowCrosshairRef convention) guarantees it's current before
126
126
  // ANY component's effects run, since React finishes rendering the whole tree first.
127
- crossHair.isMoveControlsOpen = expandedButtons?.has('moveControls')
127
+ crossHair.isMapControlsOpen = expandedButtons?.has('mapControls')
128
128
 
129
129
  return {
130
130
  crossHair,
@@ -98,20 +98,20 @@ describe('useCrossHair', () => {
98
98
  expect(mockElement.style.display).toBe('none')
99
99
  })
100
100
 
101
- it('hide() is a no-op while isMoveControlsOpen is set', () => {
101
+ it('hide() is a no-op while isMapControlsOpen is set', () => {
102
102
  setup()
103
103
  act(() => mockCrossHair.show())
104
- mockCrossHair.isMoveControlsOpen = true
104
+ mockCrossHair.isMapControlsOpen = true
105
105
 
106
106
  act(() => mockCrossHair.hide())
107
107
  expect(mockElement.style.display).toBe('block')
108
108
  expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
109
109
  })
110
110
 
111
- it('keeps crossHair.isMoveControlsOpen synced with expandedButtons, set synchronously during render (not a useEffect) so it is never stale relative to another component\'s own effect reacting to the same change', () => {
112
- useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 }, expandedButtons: new Set(['moveControls']) })
111
+ it('keeps crossHair.isMapControlsOpen synced with expandedButtons, set synchronously during render (not a useEffect) so it is never stale relative to another component\'s own effect reacting to the same change', () => {
112
+ useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 }, expandedButtons: new Set(['mapControls']) })
113
113
  setup()
114
- expect(mockCrossHair.isMoveControlsOpen).toBe(true)
114
+ expect(mockCrossHair.isMapControlsOpen).toBe(true)
115
115
  })
116
116
 
117
117
  it('setStyle updates state', () => {
@@ -32,9 +32,9 @@ export const useHintsAPI = () => {
32
32
  }, [eventBus, hints])
33
33
 
34
34
  // Escape dismisses the active hint when the keypress originates inside this
35
- // map instance. The viewport and features listbox already dismiss hints on
35
+ // map instance. The viewport and spatial listbox already dismiss hints on
36
36
  // Escape within their own narrower focus scope (useKeyboardHint.js /
37
- // useFeatureFocus.js) — this covers hints shown via the public showHint()
37
+ // useSpatialListFocus.js) — this covers hints shown via the public showHint()
38
38
  // API from anywhere else in the same map's UI (e.g. a plugin button).
39
39
  //
40
40
  // Listening on document (rather than the map's own container) and checking
@@ -51,12 +51,12 @@ export const useHintsAPI = () => {
51
51
  }, [hints])
52
52
 
53
53
  useEffect(() => {
54
- const handleKeyDown = (e) => {
55
- if (e.key !== 'Escape' || !isHintActiveRef.current) {
54
+ const handleKeyDown = (event) => {
55
+ if (event.key !== 'Escape' || !isHintActiveRef.current) {
56
56
  return
57
57
  }
58
58
  const container = layoutRefs.appContainerRef?.current
59
- if (container && !container.contains(e.target)) {
59
+ if (container && !container.contains(event.target)) {
60
60
  return
61
61
  }
62
62
  hints.dismiss()