@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,5 +1,5 @@
1
1
  import { renderHook, act } from '@testing-library/react'
2
- import { useFeatureFocus } from './useFeatureFocus.js'
2
+ import { useSpatialListFocus } from './useSpatialListFocus.js'
3
3
 
4
4
  const ITEMS = [
5
5
  { id: 'a', label: 'Feature A' },
@@ -7,17 +7,17 @@ const ITEMS = [
7
7
  { id: 'c', label: 'Feature C' }
8
8
  ]
9
9
 
10
- const SET_ACTIVE = 'map:setactivefeature' // NOSONAR
11
- const SELECT = 'map:selectfeature'
10
+ const SET_ACTIVE = 'map:setactiveitem' // NOSONAR
11
+ const SELECT = 'map:selectitem'
12
12
  const SELECTION_CHANGE = 'interact:selectionchange'
13
13
 
14
14
  const makeEventBus = () => {
15
15
  const listeners = {}
16
16
  return {
17
- on: jest.fn((e, fn) => { listeners[e] = fn }),
17
+ on: jest.fn((eventName, fn) => { listeners[eventName] = fn }),
18
18
  off: jest.fn(),
19
19
  emit: jest.fn(),
20
- trigger: (e, payload) => listeners[e]?.(payload)
20
+ trigger: (eventName, payload) => listeners[eventName]?.(payload)
21
21
  }
22
22
  }
23
23
 
@@ -26,7 +26,7 @@ const makeRefs = ({ viewportFocus } = {}) => {
26
26
  viewportEl.focus = viewportFocus ?? jest.fn()
27
27
  return {
28
28
  viewportRef: { current: viewportEl },
29
- featuresRef: { current: document.createElement('ul') },
29
+ spatialListRef: { current: document.createElement('ul') },
30
30
  hints: { subscribe: jest.fn(() => jest.fn()), dismiss: jest.fn() }
31
31
  }
32
32
  }
@@ -40,83 +40,83 @@ const fireKey = (el, key) => {
40
40
  return event
41
41
  }
42
42
 
43
- // ─── useFeatureFocus — initial state ─────────────────────────────────────────
43
+ // ─── useSpatialListFocus — initial state ─────────────────────────────────────────
44
44
 
45
- describe('useFeatureFocus — initial state', () => {
46
- it('activeFeatureId starts as null', () => {
47
- const { result } = renderHook(() => useFeatureFocus(makeRefs()))
48
- expect(result.current.activeFeatureId).toBeNull()
45
+ describe('useSpatialListFocus — initial state', () => {
46
+ it('activeItemId starts as null', () => {
47
+ const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
48
+ expect(result.current.activeItemId).toBeNull()
49
49
  })
50
50
 
51
51
  it('exposes onFocus function', () => {
52
- const { result } = renderHook(() => useFeatureFocus(makeRefs()))
52
+ const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
53
53
  expect(typeof result.current.onFocus).toBe('function')
54
54
  })
55
55
 
56
56
  it('exposes onBlur function', () => {
57
- const { result } = renderHook(() => useFeatureFocus(makeRefs()))
57
+ const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
58
58
  expect(typeof result.current.onBlur).toBe('function')
59
59
  })
60
60
  })
61
61
 
62
- // ─── useFeatureFocus — onFocus ────────────────────────────────────────────────
62
+ // ─── useSpatialListFocus — onFocus ────────────────────────────────────────────────
63
63
 
64
- describe('useFeatureFocus — onFocus', () => {
65
- it('sets activeFeatureId to first item on first focus (no previous, no selected)', () => {
66
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
64
+ describe('useSpatialListFocus — onFocus', () => {
65
+ it('sets activeItemId to first item on first focus (no previous, no selected)', () => {
66
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
67
67
  act(() => result.current.onFocus())
68
- expect(result.current.activeFeatureId).toBe('a')
68
+ expect(result.current.activeItemId).toBe('a')
69
69
  })
70
70
 
71
71
  it('does nothing when items is empty', () => {
72
- const { result } = renderHook(() => useFeatureFocus(makeRefs()))
72
+ const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
73
73
  act(() => result.current.onFocus())
74
- expect(result.current.activeFeatureId).toBeNull()
74
+ expect(result.current.activeItemId).toBeNull()
75
75
  })
76
76
 
77
77
  it('sets active to first selected item (highest priority)', () => {
78
78
  const eb = makeEventBus()
79
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
79
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
80
80
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'b', layerId: 'roads' }] }))
81
81
  act(() => result.current.onFocus())
82
- expect(result.current.activeFeatureId).toBe('b')
82
+ expect(result.current.activeItemId).toBe('b')
83
83
  })
84
84
 
85
85
  it('selected item takes priority over last active position', () => {
86
86
  const eb = makeEventBus()
87
87
  const refs = makeRefs()
88
- const el = refs.featuresRef.current
88
+ const el = refs.spatialListRef.current
89
89
  document.body.appendChild(el)
90
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
90
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS, eventBus: eb }))
91
91
  act(() => result.current.onFocus())
92
92
  fireKey(el, 'ArrowDown') // last active = 'b'
93
93
  act(() => result.current.onBlur())
94
94
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'c', layerId: 'roads' }] }))
95
95
  act(() => result.current.onFocus())
96
- expect(result.current.activeFeatureId).toBe('c') // selected beats last-active
96
+ expect(result.current.activeItemId).toBe('c') // selected beats last-active
97
97
  unmount(); el.remove()
98
98
  })
99
99
 
100
100
  it('restores last active position when nothing is selected', () => {
101
101
  const refs = makeRefs()
102
- const el = refs.featuresRef.current
102
+ const el = refs.spatialListRef.current
103
103
  document.body.appendChild(el)
104
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
104
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
105
105
  act(() => result.current.onFocus())
106
106
  fireKey(el, 'ArrowDown') // last active = 'b'
107
107
  act(() => result.current.onBlur())
108
108
  act(() => result.current.onFocus())
109
- expect(result.current.activeFeatureId).toBe('b')
109
+ expect(result.current.activeItemId).toBe('b')
110
110
  unmount(); el.remove()
111
111
  })
112
112
 
113
113
  it('falls back to first item when previous active is no longer in the list', () => {
114
114
  const eb = makeEventBus()
115
115
  const refs = makeRefs()
116
- const el = refs.featuresRef.current
116
+ const el = refs.spatialListRef.current
117
117
  document.body.appendChild(el)
118
118
  const { result, rerender, unmount } = renderHook(
119
- ({ items }) => useFeatureFocus({ ...refs, items, eventBus: eb }),
119
+ ({ items }) => useSpatialListFocus({ ...refs, items, eventBus: eb }),
120
120
  { initialProps: { items: ITEMS } }
121
121
  )
122
122
  act(() => result.current.onFocus())
@@ -125,25 +125,52 @@ describe('useFeatureFocus — onFocus', () => {
125
125
  const NEW_ITEMS = [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }]
126
126
  rerender({ items: NEW_ITEMS })
127
127
  act(() => result.current.onFocus())
128
- expect(result.current.activeFeatureId).toBe('x')
128
+ expect(result.current.activeItemId).toBe('x')
129
129
  unmount(); el.remove()
130
130
  })
131
+
132
+ it('prefers the item nearest the map center over the structurally-first one', () => {
133
+ const items = [
134
+ { id: 'a', label: 'A', x: 0, y: 0 },
135
+ { id: 'b', label: 'B', x: 100, y: 100 },
136
+ { id: 'c', label: 'C', x: 500, y: 500 }
137
+ ]
138
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items, centerScreenPoint: { x: 90, y: 90 } }))
139
+ act(() => result.current.onFocus())
140
+ expect(result.current.activeItemId).toBe('b')
141
+ })
142
+
143
+ it('falls back to the first item when no centerScreenPoint is given, even with positioned items', () => {
144
+ const items = [
145
+ { id: 'a', label: 'A', x: 0, y: 0 },
146
+ { id: 'b', label: 'B', x: 100, y: 100 }
147
+ ]
148
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items }))
149
+ act(() => result.current.onFocus())
150
+ expect(result.current.activeItemId).toBe('a')
151
+ })
152
+
153
+ it('falls back to the first item when centerScreenPoint is given but no item carries a screen position', () => {
154
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, centerScreenPoint: { x: 90, y: 90 } }))
155
+ act(() => result.current.onFocus())
156
+ expect(result.current.activeItemId).toBe('a')
157
+ })
131
158
  })
132
159
 
133
- // ─── useFeatureFocus — onBlur ─────────────────────────────────────────────────
160
+ // ─── useSpatialListFocus — onBlur ─────────────────────────────────────────────────
134
161
 
135
- describe('useFeatureFocus — onBlur', () => {
136
- it('clears activeFeatureId when focus leaves the listbox', () => {
137
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
162
+ describe('useSpatialListFocus — onBlur', () => {
163
+ it('clears activeItemId when focus leaves the listbox', () => {
164
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
138
165
  act(() => result.current.onFocus())
139
- expect(result.current.activeFeatureId).toBe('a')
166
+ expect(result.current.activeItemId).toBe('a')
140
167
  act(() => result.current.onBlur())
141
- expect(result.current.activeFeatureId).toBeNull()
168
+ expect(result.current.activeItemId).toBeNull()
142
169
  })
143
170
 
144
171
  it('emits map:setactivefeature with null on blur', () => {
145
172
  const eb = makeEventBus()
146
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
173
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
147
174
  act(() => result.current.onFocus())
148
175
  eb.emit.mockClear()
149
176
  act(() => result.current.onBlur())
@@ -151,7 +178,7 @@ describe('useFeatureFocus — onBlur', () => {
151
178
  })
152
179
 
153
180
  it('does not throw when eventBus is not provided', () => {
154
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
181
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
155
182
  expect(() => act(() => result.current.onBlur())).not.toThrow()
156
183
  })
157
184
 
@@ -159,63 +186,63 @@ describe('useFeatureFocus — onBlur', () => {
159
186
  // driven by an internal isInternalFocusMoveRef, set only while focusOption()'s own .focus()
160
187
  // call is synchronously in flight — not observable by calling onFocus/onBlur directly here,
161
188
  // since that requires a real DOM focus transition bubbling through React. See the
162
- // "Features + useFeatureFocus — roving tabindex real focus events" suite in Features.test.jsx,
189
+ // "SpatialList + useSpatialListFocus — roving tabindex real focus events" suite in SpatialList.test.jsx,
163
190
  // which renders the real markup and is the only level this can be faithfully exercised at
164
191
  // (and is where the regression this guards against was actually found).
165
192
  })
166
193
 
167
- // ─── useFeatureFocus — keydown listener lifecycle ────────────────────────────
194
+ // ─── useSpatialListFocus — keydown listener lifecycle ────────────────────────────
168
195
 
169
- describe('useFeatureFocus — keydown listener lifecycle', () => {
170
- it('does not attach listener when featuresRef.current is null', () => {
196
+ describe('useSpatialListFocus — keydown listener lifecycle', () => {
197
+ it('does not attach listener when spatialListRef.current is null', () => {
171
198
  const refs = makeRefs()
172
- refs.featuresRef = { current: null }
199
+ refs.spatialListRef = { current: null }
173
200
  const spy = jest.spyOn(document.body, 'addEventListener')
174
- renderHook(() => useFeatureFocus(refs))
201
+ renderHook(() => useSpatialListFocus(refs))
175
202
  expect(spy).not.toHaveBeenCalledWith('keydown', expect.any(Function))
176
203
  spy.mockRestore()
177
204
  })
178
205
 
179
206
  it('attaches and removes keydown listener on the features element', () => {
180
207
  const refs = makeRefs()
181
- const el = refs.featuresRef.current
208
+ const el = refs.spatialListRef.current
182
209
  const addSpy = jest.spyOn(el, 'addEventListener')
183
210
  const removeSpy = jest.spyOn(el, 'removeEventListener')
184
- const { unmount } = renderHook(() => useFeatureFocus(refs))
211
+ const { unmount } = renderHook(() => useSpatialListFocus(refs))
185
212
  expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
186
213
  unmount()
187
214
  expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
188
215
  })
189
216
  })
190
217
 
191
- // ─── useFeatureFocus — unhandled keys ────────────────────────────────────────
218
+ // ─── useSpatialListFocus — unhandled keys ────────────────────────────────────────
192
219
 
193
- describe('useFeatureFocus — unhandled keys', () => {
220
+ describe('useSpatialListFocus — unhandled keys', () => {
194
221
  it('does nothing for keys that are not Escape or Arrow', () => {
195
222
  const viewportFocus = jest.fn()
196
223
  const refs = makeRefs({ viewportFocus })
197
- const el = refs.featuresRef.current
224
+ const el = refs.spatialListRef.current
198
225
  document.body.appendChild(el)
199
- const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
226
+ const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
200
227
  fireKey(el, 'Tab')
201
- expect(result.current.activeFeatureId).toBeNull()
228
+ expect(result.current.activeItemId).toBeNull()
202
229
  expect(viewportFocus).not.toHaveBeenCalled()
203
230
  el.remove()
204
231
  })
205
232
  })
206
233
 
207
- // ─── useFeatureFocus — Escape key ────────────────────────────────────────────
234
+ // ─── useSpatialListFocus — Escape key ────────────────────────────────────────────
208
235
 
209
- describe('useFeatureFocus — Escape key', () => {
236
+ describe('useSpatialListFocus — Escape key', () => {
210
237
  it('dismisses hint on Escape when hint is visible', () => {
211
238
  const refs = makeRefs()
212
239
  refs.hints.subscribe.mockImplementation((fn) => {
213
240
  fn({ html: 'test' })
214
241
  return jest.fn()
215
242
  })
216
- const el = refs.featuresRef.current
243
+ const el = refs.spatialListRef.current
217
244
  document.body.appendChild(el)
218
- const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
245
+ const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
219
246
  act(() => result.current.onFocus())
220
247
  fireKey(el, 'Escape')
221
248
  expect(refs.hints.dismiss).toHaveBeenCalled()
@@ -226,9 +253,9 @@ describe('useFeatureFocus — Escape key', () => {
226
253
  it('focuses the viewport on Escape when no hint is visible', () => {
227
254
  const viewportFocus = jest.fn()
228
255
  const refs = makeRefs({ viewportFocus })
229
- const el = refs.featuresRef.current
256
+ const el = refs.spatialListRef.current
230
257
  document.body.appendChild(el)
231
- const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
258
+ const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
232
259
  act(() => result.current.onFocus())
233
260
  fireKey(el, 'Escape')
234
261
  expect(refs.hints.dismiss).not.toHaveBeenCalled()
@@ -237,19 +264,19 @@ describe('useFeatureFocus — Escape key', () => {
237
264
  })
238
265
 
239
266
  it('does not throw when viewportRef.current is null', () => {
240
- const refs = { viewportRef: { current: null }, featuresRef: { current: document.createElement('ul') }, hints: { subscribe: jest.fn(() => jest.fn()), dismiss: jest.fn() } }
241
- const el = refs.featuresRef.current
267
+ const refs = { viewportRef: { current: null }, spatialListRef: { current: document.createElement('ul') }, hints: { subscribe: jest.fn(() => jest.fn()), dismiss: jest.fn() } }
268
+ const el = refs.spatialListRef.current
242
269
  document.body.appendChild(el)
243
- renderHook(() => useFeatureFocus(refs))
270
+ renderHook(() => useSpatialListFocus(refs))
244
271
  expect(() => fireKey(el, 'Escape')).not.toThrow()
245
272
  el.remove()
246
273
  })
247
274
 
248
275
  it('stops propagation on Escape so the viewport keyboard handler does not also handle it', () => {
249
276
  const refs = makeRefs()
250
- const el = refs.featuresRef.current
277
+ const el = refs.spatialListRef.current
251
278
  document.body.appendChild(el)
252
- renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
279
+ renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
253
280
  const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
254
281
  const stopSpy = jest.spyOn(event, 'stopPropagation')
255
282
  act(() => el.dispatchEvent(event))
@@ -258,21 +285,21 @@ describe('useFeatureFocus — Escape key', () => {
258
285
  })
259
286
  })
260
287
 
261
- // ─── useFeatureFocus — ArrowDown navigation ───────────────────────────────────
288
+ // ─── useSpatialListFocus — ArrowDown navigation ───────────────────────────────────
262
289
 
263
- describe('useFeatureFocus — ArrowDown navigation', () => {
290
+ describe('useSpatialListFocus — ArrowDown navigation', () => {
264
291
  const setup = () => {
265
292
  const refs = makeRefs()
266
- const el = refs.featuresRef.current
293
+ const el = refs.spatialListRef.current
267
294
  document.body.appendChild(el)
268
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
295
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
269
296
  return { result, el, unmount }
270
297
  }
271
298
 
272
299
  it('selects first item when no item is active', () => {
273
300
  const { result, el, unmount } = setup()
274
301
  fireKey(el, 'ArrowDown')
275
- expect(result.current.activeFeatureId).toBe('a')
302
+ expect(result.current.activeItemId).toBe('a')
276
303
  unmount(); el.remove()
277
304
  })
278
305
 
@@ -280,30 +307,30 @@ describe('useFeatureFocus — ArrowDown navigation', () => {
280
307
  const { result, el, unmount } = setup()
281
308
  act(() => result.current.onFocus())
282
309
  fireKey(el, 'ArrowDown')
283
- expect(result.current.activeFeatureId).toBe('b')
310
+ expect(result.current.activeItemId).toBe('b')
284
311
  unmount(); el.remove()
285
312
  })
286
313
 
287
314
  it('clamps at last item', () => {
288
315
  const refs = makeRefs()
289
- const el = refs.featuresRef.current
316
+ const el = refs.spatialListRef.current
290
317
  document.body.appendChild(el)
291
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
318
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
292
319
  act(() => result.current.onFocus())
293
320
  fireKey(el, 'ArrowDown')
294
321
  fireKey(el, 'ArrowDown')
295
322
  fireKey(el, 'ArrowDown')
296
- expect(result.current.activeFeatureId).toBe('c')
323
+ expect(result.current.activeItemId).toBe('c')
297
324
  unmount(); el.remove()
298
325
  })
299
326
 
300
327
  it('does nothing when items is empty', () => {
301
328
  const refs = makeRefs()
302
- const el = refs.featuresRef.current
329
+ const el = refs.spatialListRef.current
303
330
  document.body.appendChild(el)
304
- const { result, unmount } = renderHook(() => useFeatureFocus(refs))
331
+ const { result, unmount } = renderHook(() => useSpatialListFocus(refs))
305
332
  fireKey(el, 'ArrowDown')
306
- expect(result.current.activeFeatureId).toBeNull()
333
+ expect(result.current.activeItemId).toBeNull()
307
334
  unmount(); el.remove()
308
335
  })
309
336
 
@@ -318,52 +345,171 @@ describe('useFeatureFocus — ArrowDown navigation', () => {
318
345
  })
319
346
  })
320
347
 
321
- // ─── useFeatureFocus — ArrowUp navigation ────────────────────────────────────
348
+ // ─── useSpatialListFocus — ArrowUp navigation ────────────────────────────────────
322
349
 
323
- describe('useFeatureFocus — ArrowUp navigation', () => {
350
+ describe('useSpatialListFocus — ArrowUp navigation', () => {
324
351
  it('selects last item when no item is active', () => {
325
352
  const refs = makeRefs()
326
- const el = refs.featuresRef.current
353
+ const el = refs.spatialListRef.current
327
354
  document.body.appendChild(el)
328
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
355
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
329
356
  fireKey(el, 'ArrowUp')
330
- expect(result.current.activeFeatureId).toBe('c')
357
+ expect(result.current.activeItemId).toBe('c')
331
358
  unmount(); el.remove()
332
359
  })
333
360
 
334
361
  it('moves to previous item', () => {
335
362
  const refs = makeRefs()
336
- const el = refs.featuresRef.current
363
+ const el = refs.spatialListRef.current
337
364
  document.body.appendChild(el)
338
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
365
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
339
366
  act(() => result.current.onFocus())
340
367
  fireKey(el, 'ArrowDown')
341
368
  fireKey(el, 'ArrowUp')
342
- expect(result.current.activeFeatureId).toBe('a')
369
+ expect(result.current.activeItemId).toBe('a')
343
370
  unmount(); el.remove()
344
371
  })
345
372
 
346
373
  it('clamps at first item', () => {
347
374
  const refs = makeRefs()
348
- const el = refs.featuresRef.current
375
+ const el = refs.spatialListRef.current
349
376
  document.body.appendChild(el)
350
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
377
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
351
378
  act(() => result.current.onFocus())
352
379
  fireKey(el, 'ArrowUp')
353
380
  fireKey(el, 'ArrowUp')
354
- expect(result.current.activeFeatureId).toBe('a')
381
+ expect(result.current.activeItemId).toBe('a')
355
382
  unmount(); el.remove()
356
383
  })
357
384
  })
358
385
 
359
- // ─── useFeatureFocusHome/End navigation ───────────────────────────────────
386
+ // ─── useSpatialListFocusAlt+Arrow spatial navigation ──────────────────────────
387
+
388
+ describe('useSpatialListFocus — Alt+Arrow spatial navigation', () => {
389
+ const POSITIONED_ITEMS = [
390
+ { id: 'center', label: 'Center', x: 0, y: 0 },
391
+ { id: 'up', label: 'Up', x: 0, y: -10 },
392
+ { id: 'right', label: 'Right', x: 10, y: 0 }
393
+ ]
360
394
 
361
- describe('useFeatureFocus Home/End navigation', () => {
395
+ const setup = (items = POSITIONED_ITEMS) => {
396
+ const refs = makeRefs()
397
+ const el = refs.spatialListRef.current
398
+ document.body.appendChild(el)
399
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items }))
400
+ return { result, el, unmount }
401
+ }
402
+
403
+ const fireAltKey = (el, key, type = 'keydown') => {
404
+ let event
405
+ act(() => {
406
+ event = new KeyboardEvent(type, { key, altKey: true, bubbles: true, cancelable: true })
407
+ el.dispatchEvent(event)
408
+ })
409
+ return event
410
+ }
411
+
412
+ it('moves to the item spatially nearest in the pressed direction, not the next item in list order', () => {
413
+ const { result, el, unmount } = setup()
414
+ act(() => result.current.onFocus()) // resolves to 'center', the first item
415
+ fireAltKey(el, 'ArrowRight')
416
+ expect(result.current.activeItemId).toBe('right')
417
+ unmount(); el.remove()
418
+ })
419
+
420
+ it('emits map:setactivefeature for the spatially-found item', () => {
421
+ const refs = makeRefs()
422
+ const el = refs.spatialListRef.current
423
+ document.body.appendChild(el)
424
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: POSITIONED_ITEMS, eventBus: makeEventBus() }))
425
+ act(() => result.current.onFocus())
426
+ fireAltKey(el, 'ArrowUp')
427
+ expect(result.current.activeItemId).toBe('up')
428
+ unmount(); el.remove()
429
+ })
430
+
431
+ it('prevents the browser default (e.g. Alt+Arrow history navigation) on keydown', () => {
432
+ const { result, el, unmount } = setup()
433
+ act(() => result.current.onFocus())
434
+ let event
435
+ act(() => {
436
+ event = new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true, cancelable: true })
437
+ })
438
+ const preventSpy = jest.spyOn(event, 'preventDefault')
439
+ act(() => el.dispatchEvent(event))
440
+ expect(preventSpy).toHaveBeenCalled()
441
+ unmount(); el.remove()
442
+ })
443
+
444
+ it('stops the matching keyup from bubbling to the viewport label-navigation binding on a shared ancestor', () => {
445
+ const { result, el, unmount } = setup()
446
+ act(() => result.current.onFocus())
447
+ let event
448
+ act(() => {
449
+ event = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: true, bubbles: true, cancelable: true })
450
+ })
451
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
452
+ act(() => el.dispatchEvent(event))
453
+ expect(stopSpy).toHaveBeenCalled()
454
+ unmount(); el.remove()
455
+ })
456
+
457
+ it('leaves a plain (non-Alt) arrow keyup alone — sequential navigation has already handled it on keydown', () => {
458
+ const { result, el, unmount } = setup()
459
+ act(() => result.current.onFocus())
460
+ let event
461
+ act(() => {
462
+ event = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: false, bubbles: true, cancelable: true })
463
+ })
464
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
465
+ act(() => el.dispatchEvent(event))
466
+ expect(stopSpy).not.toHaveBeenCalled()
467
+ unmount(); el.remove()
468
+ })
469
+
470
+ it('stops an Alt+Enter keyup from bubbling to the viewport center-label binding on a shared ancestor', () => {
471
+ const { result, el, unmount } = setup()
472
+ act(() => result.current.onFocus())
473
+ let event
474
+ act(() => {
475
+ event = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, bubbles: true, cancelable: true })
476
+ })
477
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
478
+ act(() => el.dispatchEvent(event))
479
+ expect(stopSpy).toHaveBeenCalled()
480
+ unmount(); el.remove()
481
+ })
482
+
483
+ it('leaves an Alt+<other> keyup alone — nothing else uses this modifier here', () => {
484
+ const { result, el, unmount } = setup()
485
+ act(() => result.current.onFocus())
486
+ let event
487
+ act(() => {
488
+ event = new KeyboardEvent('keyup', { key: 'i', altKey: true, bubbles: true, cancelable: true })
489
+ })
490
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
491
+ act(() => el.dispatchEvent(event))
492
+ expect(stopSpy).not.toHaveBeenCalled()
493
+ unmount(); el.remove()
494
+ })
495
+
496
+ it('is a no-op when no item lies in the pressed direction — stays on the current item', () => {
497
+ const { result, el, unmount } = setup([{ id: 'only', label: 'Only', x: 0, y: 0 }])
498
+ act(() => result.current.onFocus())
499
+ fireAltKey(el, 'ArrowRight')
500
+ expect(result.current.activeItemId).toBe('only')
501
+ unmount(); el.remove()
502
+ })
503
+ })
504
+
505
+ // ─── useSpatialListFocus — Home/End navigation ───────────────────────────────────
506
+
507
+ describe('useSpatialListFocus — Home/End navigation', () => {
362
508
  const setup = () => {
363
509
  const refs = makeRefs()
364
- const el = refs.featuresRef.current
510
+ const el = refs.spatialListRef.current
365
511
  document.body.appendChild(el)
366
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
512
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
367
513
  return { result, el, unmount }
368
514
  }
369
515
 
@@ -372,7 +518,7 @@ describe('useFeatureFocus — Home/End navigation', () => {
372
518
  act(() => result.current.onFocus())
373
519
  fireKey(el, 'ArrowDown')
374
520
  fireKey(el, 'Home')
375
- expect(result.current.activeFeatureId).toBe('a')
521
+ expect(result.current.activeItemId).toBe('a')
376
522
  unmount(); el.remove()
377
523
  })
378
524
 
@@ -380,18 +526,18 @@ describe('useFeatureFocus — Home/End navigation', () => {
380
526
  const { result, el, unmount } = setup()
381
527
  act(() => result.current.onFocus())
382
528
  fireKey(el, 'End')
383
- expect(result.current.activeFeatureId).toBe('c')
529
+ expect(result.current.activeItemId).toBe('c')
384
530
  unmount(); el.remove()
385
531
  })
386
532
 
387
533
  it('does nothing when items is empty', () => {
388
534
  const refs = makeRefs()
389
- const el = refs.featuresRef.current
535
+ const el = refs.spatialListRef.current
390
536
  document.body.appendChild(el)
391
- const { result, unmount } = renderHook(() => useFeatureFocus(refs))
537
+ const { result, unmount } = renderHook(() => useSpatialListFocus(refs))
392
538
  fireKey(el, 'Home')
393
539
  fireKey(el, 'End')
394
- expect(result.current.activeFeatureId).toBeNull()
540
+ expect(result.current.activeItemId).toBeNull()
395
541
  unmount(); el.remove()
396
542
  })
397
543
 
@@ -409,9 +555,9 @@ describe('useFeatureFocus — Home/End navigation', () => {
409
555
  it('emits map:setactivefeature with the boundary id', () => {
410
556
  const eb = makeEventBus()
411
557
  const refs = makeRefs()
412
- const el = refs.featuresRef.current
558
+ const el = refs.spatialListRef.current
413
559
  document.body.appendChild(el)
414
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
560
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS, eventBus: eb }))
415
561
  act(() => result.current.onFocus())
416
562
  eb.emit.mockClear()
417
563
  fireKey(el, 'End')
@@ -420,43 +566,43 @@ describe('useFeatureFocus — Home/End navigation', () => {
420
566
  })
421
567
  })
422
568
 
423
- // ─── useFeatureFocus — eventBus integration ───────────────────────────────────
569
+ // ─── useSpatialListFocus — eventBus integration ───────────────────────────────────
424
570
 
425
- describe('useFeatureFocus — eventBus: map:setactivefeature listener', () => {
571
+ describe('useSpatialListFocus — eventBus: map:setactivefeature listener', () => {
426
572
  it('subscribes to map:setactivefeature on mount and unsubscribes on unmount', () => {
427
573
  const eb = makeEventBus()
428
- const { unmount } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
574
+ const { unmount } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
429
575
  expect(eb.on).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function))
430
576
  unmount()
431
577
  expect(eb.off).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function))
432
578
  })
433
579
 
434
- it('updates activeFeatureId when map:setactivefeature is received', () => {
580
+ it('updates activeItemId when map:setactivefeature is received', () => {
435
581
  const eb = makeEventBus()
436
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
582
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
437
583
  act(() => eb.trigger(SET_ACTIVE, { id: 'a' }))
438
- expect(result.current.activeFeatureId).toBe('a')
584
+ expect(result.current.activeItemId).toBe('a')
439
585
  })
440
586
 
441
- it('clears activeFeatureId when map:setactivefeature is received with null', () => {
587
+ it('clears activeItemId when map:setactivefeature is received with null', () => {
442
588
  const eb = makeEventBus()
443
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
589
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
444
590
  act(() => eb.trigger(SET_ACTIVE, { id: 'a' }))
445
591
  act(() => eb.trigger(SET_ACTIVE, { id: null }))
446
- expect(result.current.activeFeatureId).toBeNull()
592
+ expect(result.current.activeItemId).toBeNull()
447
593
  })
448
594
  })
449
595
 
450
596
  const setupWithBus = () => {
451
597
  const eb = makeEventBus()
452
598
  const refs = makeRefs()
453
- const el = refs.featuresRef.current
599
+ const el = refs.spatialListRef.current
454
600
  document.body.appendChild(el)
455
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
601
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS, eventBus: eb }))
456
602
  return { eb, el, result, unmount }
457
603
  }
458
604
 
459
- describe('useFeatureFocus — eventBus: map:setactivefeature emit', () => {
605
+ describe('useSpatialListFocus — eventBus: map:setactivefeature emit', () => {
460
606
  afterEach(() => { document.body.innerHTML = '' })
461
607
 
462
608
  it('emits map:setactivefeature with first item id on onFocus', () => {
@@ -476,9 +622,9 @@ describe('useFeatureFocus — eventBus: map:setactivefeature emit', () => {
476
622
 
477
623
  it('does not emit when eventBus is not provided', () => {
478
624
  const refs = makeRefs()
479
- const el = refs.featuresRef.current
625
+ const el = refs.spatialListRef.current
480
626
  document.body.appendChild(el)
481
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
627
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
482
628
  expect(() => {
483
629
  act(() => result.current.onFocus())
484
630
  fireKey(el, 'ArrowDown')
@@ -487,12 +633,12 @@ describe('useFeatureFocus — eventBus: map:setactivefeature emit', () => {
487
633
  })
488
634
  })
489
635
 
490
- // ─── useFeatureFocus — interact:selectionchange listener ─────────────────────
636
+ // ─── useSpatialListFocus — interact:selectionchange listener ─────────────────────
491
637
 
492
- describe('useFeatureFocus — interact:selectionchange listener', () => {
638
+ describe('useSpatialListFocus — interact:selectionchange listener', () => {
493
639
  it('subscribes to interact:selectionchange on mount and unsubscribes on unmount', () => {
494
640
  const eb = makeEventBus()
495
- const { unmount } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
641
+ const { unmount } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
496
642
  expect(eb.on).toHaveBeenCalledWith(SELECTION_CHANGE, expect.any(Function))
497
643
  unmount()
498
644
  expect(eb.off).toHaveBeenCalledWith(SELECTION_CHANGE, expect.any(Function))
@@ -500,51 +646,51 @@ describe('useFeatureFocus — interact:selectionchange listener', () => {
500
646
 
501
647
  it('sets selectedIds from selected features', () => {
502
648
  const eb = makeEventBus()
503
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
649
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
504
650
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 27665979, layerId: 'hedges' }] }))
505
651
  expect(result.current.selectedIds).toEqual(['27665979'])
506
652
  })
507
653
 
508
654
  it('sets selectedIds from multiple selected features', () => {
509
655
  const eb = makeEventBus()
510
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
656
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
511
657
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }, { featureId: 'f2', layerId: 'roads' }] }))
512
658
  expect(result.current.selectedIds).toEqual(['f1', 'f2'])
513
659
  })
514
660
 
515
661
  it('sets selectedIds from selected markers', () => {
516
662
  const eb = makeEventBus()
517
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
663
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
518
664
  act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['m1', 'm2'] }))
519
665
  expect(result.current.selectedIds).toEqual(['m1', 'm2'])
520
666
  })
521
667
 
522
668
  it('sets selectedIds from both features and markers when both are selected', () => {
523
669
  const eb = makeEventBus()
524
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
670
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
525
671
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }], selectedMarkers: ['m1'] }))
526
672
  expect(result.current.selectedIds).toEqual(['f1', 'm1'])
527
673
  })
528
674
 
529
675
  it('clears selectedIds when selection becomes empty', () => {
530
676
  const eb = makeEventBus()
531
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
677
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
532
678
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }] }))
533
679
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [], selectedMarkers: [] }))
534
680
  expect(result.current.selectedIds).toEqual([])
535
681
  })
536
682
 
537
- it('does not move activeFeatureId when selectionchange fires (cursor is keyboard-only)', () => {
683
+ it('does not move activeItemId when selectionchange fires (cursor is keyboard-only)', () => {
538
684
  const eb = makeEventBus()
539
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
685
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
540
686
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'a', layerId: 'roads' }] }))
541
- expect(result.current.activeFeatureId).toBeNull()
687
+ expect(result.current.activeItemId).toBeNull()
542
688
  })
543
689
  })
544
690
 
545
- // ─── useFeatureFocus — map interaction returns focus to viewport ──────────────
691
+ // ─── useSpatialListFocus — map interaction returns focus to viewport ──────────────
546
692
 
547
- describe('useFeatureFocus — map pointerdown returns focus to viewport', () => {
693
+ describe('useSpatialListFocus — map pointerdown returns focus to viewport', () => {
548
694
  const pointerDown = (target) => act(() => {
549
695
  target.dispatchEvent(new Event('pointerdown', { bubbles: true, cancelable: true }))
550
696
  })
@@ -555,7 +701,7 @@ describe('useFeatureFocus — map pointerdown returns focus to viewport', () =>
555
701
  document.body.appendChild(refs.viewportRef.current)
556
702
  const mapCanvas = document.createElement('canvas')
557
703
  refs.viewportRef.current.appendChild(mapCanvas)
558
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
704
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
559
705
  act(() => result.current.onFocus())
560
706
  viewportFocus.mockClear()
561
707
  pointerDown(mapCanvas)
@@ -567,10 +713,10 @@ describe('useFeatureFocus — map pointerdown returns focus to viewport', () =>
567
713
  const viewportFocus = jest.fn()
568
714
  const refs = makeRefs({ viewportFocus })
569
715
  document.body.appendChild(refs.viewportRef.current)
570
- refs.viewportRef.current.appendChild(refs.featuresRef.current)
716
+ refs.viewportRef.current.appendChild(refs.spatialListRef.current)
571
717
  const option = document.createElement('li')
572
- refs.featuresRef.current.appendChild(option)
573
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
718
+ refs.spatialListRef.current.appendChild(option)
719
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
574
720
  act(() => result.current.onFocus())
575
721
  viewportFocus.mockClear()
576
722
  pointerDown(option)
@@ -584,7 +730,7 @@ describe('useFeatureFocus — map pointerdown returns focus to viewport', () =>
584
730
  document.body.appendChild(refs.viewportRef.current)
585
731
  const mapCanvas = document.createElement('canvas')
586
732
  refs.viewportRef.current.appendChild(mapCanvas)
587
- const { unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
733
+ const { unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
588
734
  pointerDown(mapCanvas)
589
735
  expect(viewportFocus).not.toHaveBeenCalled()
590
736
  unmount()
@@ -593,9 +739,9 @@ describe('useFeatureFocus — map pointerdown returns focus to viewport', () =>
593
739
  afterEach(() => { document.body.innerHTML = '' })
594
740
  })
595
741
 
596
- // ─── useFeatureFocus — Enter and Space keys ───────────────────────────────────
742
+ // ─── useSpatialListFocus — Enter and Space keys ───────────────────────────────────
597
743
 
598
- describe('useFeatureFocus — Enter and Space keys', () => {
744
+ describe('useSpatialListFocus — Enter and Space keys', () => {
599
745
  afterEach(() => { document.body.innerHTML = '' })
600
746
 
601
747
  it('emits map:selectfeature on Enter', () => {
@@ -637,17 +783,17 @@ describe('useFeatureFocus — Enter and Space keys', () => {
637
783
  })
638
784
  })
639
785
 
640
- // ─── useFeatureFocus — items change while focused ─────────────────────────────
786
+ // ─── useSpatialListFocus — items change while focused ─────────────────────────────
641
787
 
642
- describe('useFeatureFocus — items change while focused', () => {
788
+ describe('useSpatialListFocus — items change while focused', () => {
643
789
  afterEach(() => { document.body.innerHTML = '' })
644
790
 
645
791
  const setupRerender = (initialItems) => {
646
792
  const eb = makeEventBus()
647
793
  const refs = makeRefs()
648
- document.body.appendChild(refs.featuresRef.current)
794
+ document.body.appendChild(refs.spatialListRef.current)
649
795
  const { result, rerender, unmount } = renderHook(
650
- ({ items }) => useFeatureFocus({ ...refs, items, eventBus: eb }),
796
+ ({ items }) => useSpatialListFocus({ ...refs, items, eventBus: eb }),
651
797
  { initialProps: { items: initialItems } }
652
798
  )
653
799
  return { eb, result, rerender, unmount }
@@ -657,7 +803,7 @@ describe('useFeatureFocus — items change while focused', () => {
657
803
  const { result, rerender, unmount } = setupRerender(ITEMS)
658
804
  act(() => result.current.onFocus()) // active = 'a'
659
805
  act(() => { rerender({ items: [{ id: 'a', label: 'A updated' }, { id: 'd', label: 'D' }] }) })
660
- expect(result.current.activeFeatureId).toBe('a')
806
+ expect(result.current.activeItemId).toBe('a')
661
807
  unmount()
662
808
  })
663
809
 
@@ -666,7 +812,7 @@ describe('useFeatureFocus — items change while focused', () => {
666
812
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'c', layerId: 'l' }] }))
667
813
  act(() => result.current.onFocus()) // active = 'a' (first, no prev)
668
814
  act(() => { rerender({ items: [{ id: 'c', label: 'C' }, { id: 'd', label: 'D' }] }) }) // 'a' gone
669
- expect(result.current.activeFeatureId).toBe('c') // first selected
815
+ expect(result.current.activeItemId).toBe('c') // first selected
670
816
  unmount()
671
817
  })
672
818
 
@@ -674,7 +820,7 @@ describe('useFeatureFocus — items change while focused', () => {
674
820
  const { result, rerender, unmount } = setupRerender(ITEMS)
675
821
  act(() => result.current.onFocus()) // active = 'a'
676
822
  act(() => { rerender({ items: [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }] }) })
677
- expect(result.current.activeFeatureId).toBe('x')
823
+ expect(result.current.activeItemId).toBe('x')
678
824
  unmount()
679
825
  })
680
826
 
@@ -682,7 +828,7 @@ describe('useFeatureFocus — items change while focused', () => {
682
828
  const { result, rerender, unmount } = setupRerender(ITEMS)
683
829
  // never called onFocus — isFocusedRef stays false
684
830
  act(() => { rerender({ items: [{ id: 'x', label: 'X' }] }) })
685
- expect(result.current.activeFeatureId).toBeNull()
831
+ expect(result.current.activeItemId).toBeNull()
686
832
  unmount()
687
833
  })
688
834
 
@@ -691,7 +837,7 @@ describe('useFeatureFocus — items change while focused', () => {
691
837
  act(() => result.current.onFocus())
692
838
  eb.emit.mockClear()
693
839
  act(() => { rerender({ items: [{ id: 'x', label: 'X' }] }) }) // 'a' gone
694
- expect(eb.emit).toHaveBeenCalledWith('map:setactivefeature', { id: 'x' })
840
+ expect(eb.emit).toHaveBeenCalledWith('map:setactiveitem', { id: 'x' })
695
841
  unmount()
696
842
  })
697
843
 
@@ -700,22 +846,31 @@ describe('useFeatureFocus — items change while focused', () => {
700
846
  act(() => result.current.onFocus())
701
847
  eb.emit.mockClear()
702
848
  act(() => { rerender({ items: [] }) })
703
- expect(result.current.activeFeatureId).toBeNull()
704
- expect(eb.emit).toHaveBeenCalledWith('map:setactivefeature', { id: null })
849
+ expect(result.current.activeItemId).toBeNull()
850
+ expect(eb.emit).toHaveBeenCalledWith('map:setactiveitem', { id: null })
705
851
  unmount()
706
852
  })
707
853
  })
708
854
 
709
- // ─── useFeatureFocus — tabbableId (roving tabindex resting position) ─────────
855
+ // ─── useSpatialListFocus — tabbableId (roving tabindex resting position) ─────────
710
856
 
711
- describe('useFeatureFocus — tabbableId', () => {
857
+ describe('useSpatialListFocus — tabbableId', () => {
712
858
  it('is null when items is empty', () => {
713
- const { result } = renderHook(() => useFeatureFocus(makeRefs()))
859
+ const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
714
860
  expect(result.current.tabbableId).toBeNull()
715
861
  })
716
862
 
863
+ it('defaults to the item nearest the map center before the list has ever been focused', () => {
864
+ const items = [
865
+ { id: 'a', label: 'A', x: 0, y: 0 },
866
+ { id: 'b', label: 'B', x: 100, y: 100 }
867
+ ]
868
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items, centerScreenPoint: { x: 90, y: 90 } }))
869
+ expect(result.current.tabbableId).toBe('b')
870
+ })
871
+
717
872
  it('defaults to the first item before the list has ever been focused', () => {
718
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
873
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
719
874
  expect(result.current.tabbableId).toBe('a')
720
875
  })
721
876
 
@@ -725,14 +880,14 @@ describe('useFeatureFocus — tabbableId', () => {
725
880
  // tabIndex to match it on every click.
726
881
  it('is not influenced by a selection change before any position is established', () => {
727
882
  const eb = makeEventBus()
728
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
883
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
729
884
  act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'b', layerId: 'roads' }] }))
730
885
  expect(result.current.tabbableId).toBe('a')
731
886
  })
732
887
 
733
888
  it('does not chase a single item toggled selected/deselected purely via mouse clicks, with no keyboard interaction at all', () => {
734
889
  const eb = makeEventBus()
735
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
890
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
736
891
  act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['a'] }))
737
892
  expect(result.current.tabbableId).toBe('a')
738
893
  act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['b'] })) // clicked a different marker
@@ -744,9 +899,9 @@ describe('useFeatureFocus — tabbableId', () => {
744
899
  it('stays on the established position when a selection change arrives from elsewhere (e.g. clicking a marker on the map)', () => {
745
900
  const eb = makeEventBus()
746
901
  const refs = makeRefs()
747
- const el = refs.featuresRef.current
902
+ const el = refs.spatialListRef.current
748
903
  document.body.appendChild(el)
749
- const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
904
+ const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS, eventBus: eb }))
750
905
  act(() => result.current.onFocus()) // establishes position at 'a'
751
906
  fireKey(el, 'ArrowDown') // establishes position at 'b'
752
907
  act(() => result.current.onBlur())
@@ -758,20 +913,20 @@ describe('useFeatureFocus — tabbableId', () => {
758
913
  })
759
914
  })
760
915
 
761
- // ─── useFeatureFocus — selectItem (click / touch / voice-control activation) ─
916
+ // ─── useSpatialListFocus — selectItem (click / touch / voice-control activation) ─
762
917
 
763
- describe('useFeatureFocus — selectItem', () => {
918
+ describe('useSpatialListFocus — selectItem', () => {
764
919
  afterEach(() => { document.body.innerHTML = '' })
765
920
 
766
- it('sets activeFeatureId to the clicked item', () => {
767
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
921
+ it('sets activeItemId to the clicked item', () => {
922
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
768
923
  act(() => result.current.selectItem('b'))
769
- expect(result.current.activeFeatureId).toBe('b')
924
+ expect(result.current.activeItemId).toBe('b')
770
925
  })
771
926
 
772
927
  it('emits map:setactivefeature then map:selectfeature for the clicked item', () => {
773
928
  const eb = makeEventBus()
774
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
929
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
775
930
  act(() => result.current.selectItem('b'))
776
931
  expect(eb.emit).toHaveBeenNthCalledWith(1, SET_ACTIVE, { id: 'b' })
777
932
  expect(eb.emit).toHaveBeenNthCalledWith(2, SELECT)
@@ -782,21 +937,21 @@ describe('useFeatureFocus — selectItem', () => {
782
937
  const option = document.createElement('li')
783
938
  option.dataset.id = 'b'
784
939
  option.focus = jest.fn()
785
- refs.featuresRef.current.appendChild(option)
786
- const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
940
+ refs.spatialListRef.current.appendChild(option)
941
+ const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
787
942
  act(() => result.current.selectItem('b'))
788
943
  expect(option.focus).toHaveBeenCalledWith({ preventScroll: true })
789
944
  })
790
945
 
791
946
  it('does not throw when eventBus is not provided', () => {
792
- const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
947
+ const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
793
948
  expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
794
949
  })
795
950
 
796
- it('does not throw when featuresRef.current is null (e.g. unmounted)', () => {
797
- const refs = { ...makeRefs(), featuresRef: { current: null } }
798
- const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
951
+ it('does not throw when spatialListRef.current is null (e.g. unmounted)', () => {
952
+ const refs = { ...makeRefs(), spatialListRef: { current: null } }
953
+ const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
799
954
  expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
800
- expect(result.current.activeFeatureId).toBe('b') // state still updates even though there's no element to focus
955
+ expect(result.current.activeItemId).toBe('b') // state still updates even though there's no element to focus
801
956
  })
802
957
  })