@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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api.md +16 -8
- package/docs/plugins/draw.md +1 -1
- package/package.json +1 -1
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/datasets/dist/css/index.css +1 -1
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
- package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
- package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
- package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/esm/index.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/umd/index.js +1 -1
- package/plugins/draw/src/DrawInit.jsx +31 -26
- package/plugins/draw/src/DrawInit.test.jsx +55 -12
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
- package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
- package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +8 -2
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +35 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
- package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
- package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +31 -17
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
- package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
- package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +32 -57
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +14 -13
- package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
- package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
- package/plugins/draw/src/events.js +4 -4
- package/plugins/draw/src/events.test.js +1 -1
- package/plugins/draw/src/hooks/useSpatialList.js +180 -0
- package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
- package/plugins/draw/src/utils/spatial.js +1 -31
- package/plugins/draw/src/utils/spatial.test.js +1 -24
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +2 -2
- package/plugins/interact/src/InteractInit.test.js +3 -3
- package/plugins/interact/src/events.js +5 -0
- package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
- package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
- package/plugins/interact/src/hooks/useCrossHairVisibility.js +4 -4
- package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
- package/plugins/interact/src/hooks/{useMapItemList.js → useSpatialList.js} +87 -52
- package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +152 -71
- package/plugins/interact/src/manifest.js +12 -1
- package/plugins/interact/src/manifest.test.js +20 -0
- package/plugins/interact/src/utils/spatial.js +1 -1
- package/providers/beta/openlayers/src/utils/queryFeatures.js +1 -1
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/utils/labels.js +2 -2
- package/providers/maplibre/src/utils/labels.test.js +5 -5
- package/providers/maplibre/src/utils/spatial.js +0 -57
- package/providers/maplibre/src/utils/spatial.test.js +0 -27
- package/src/App/components/CrossHair/CrossHair.jsx +10 -4
- package/src/App/components/CrossHair/CrossHair.module.scss +16 -12
- package/src/App/components/CrossHair/CrossHair.test.jsx +9 -4
- package/src/App/components/Hints/Hints.jsx +3 -3
- package/src/App/components/Hints/Hints.test.jsx +3 -3
- package/src/App/components/MapButton/MapButton.module.scss +1 -2
- package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
- package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
- package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
- package/src/App/components/Markers/Markers.jsx +2 -2
- package/src/App/components/Markers/Markers.test.jsx +3 -3
- package/src/App/components/Markers/SymbolMarker.test.jsx +2 -2
- package/src/App/components/Viewport/MapStatus.jsx +1 -1
- package/src/App/components/Viewport/SpatialList.jsx +47 -0
- package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +3 -3
- package/src/App/components/Viewport/{Features.test.jsx → SpatialList.test.jsx} +60 -43
- package/src/App/components/Viewport/Viewport.jsx +20 -10
- package/src/App/components/Viewport/Viewport.test.jsx +18 -2
- package/src/App/hooks/useCrossHairAPI.js +5 -5
- package/src/App/hooks/useCrossHairAPI.test.js +5 -5
- package/src/App/hooks/useHintsAPI.js +5 -5
- package/src/App/hooks/useSpatialListFocus.js +296 -0
- package/src/App/hooks/{useFeatureFocus.test.js → useSpatialListFocus.test.js} +318 -163
- package/src/App/hooks/useSpatialListItems.js +51 -0
- package/src/App/hooks/useSpatialListItems.test.js +217 -0
- package/src/App/initialiseApp.js +6 -3
- package/src/App/initialiseApp.test.js +11 -0
- package/src/App/registry/spatialListRegistry.js +104 -0
- package/src/App/registry/spatialListRegistry.test.js +218 -0
- package/src/App/store/AppProvider.jsx +3 -2
- package/src/config/appConfig.js +14 -14
- package/src/config/appConfig.test.js +19 -19
- package/src/config/defaults.js +2 -2
- package/src/config/events.js +8 -8
- package/src/scss/main.scss +2 -2
- package/src/test-utils.js +8 -0
- package/src/types.js +11 -9
- package/src/utils/findNearestItemInDirection.js +29 -0
- package/src/utils/findNearestItemInDirection.test.js +67 -0
- package/src/utils/globalAltShortcuts.js +12 -0
- package/src/utils/globalAltShortcuts.test.js +27 -0
- package/src/utils/spatialNavigate.js +49 -0
- package/src/utils/spatialNavigate.test.js +42 -0
- package/src/App/components/Viewport/Features.jsx +0 -49
- package/src/App/hooks/useFeatureFocus.js +0 -257
- package/src/App/hooks/useFeatureItems.js +0 -39
- package/src/App/hooks/useFeatureItems.test.js +0 -155
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { EVENTS } from '../../config/events.js'
|
|
3
|
+
import { findNearestItemInDirection } from '../../utils/findNearestItemInDirection.js'
|
|
4
|
+
import { spatialNavigate } from '../../utils/spatialNavigate.js'
|
|
5
|
+
import { stopIfGlobalAltKey } from '../../utils/globalAltShortcuts.js'
|
|
6
|
+
|
|
7
|
+
const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
|
|
8
|
+
|
|
9
|
+
// Whichever item is nearest the map's own center point on screen — the entry-point fallback
|
|
10
|
+
// below prefers this over the structurally-first item.
|
|
11
|
+
const findNearestToCenter = (items, centerScreenPoint) => {
|
|
12
|
+
if (!centerScreenPoint) {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
const positioned = items.filter(item => item.x != null && item.y != null)
|
|
16
|
+
if (!positioned.length) {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
const pixels = positioned.map(item => [item.x, item.y])
|
|
20
|
+
const idx = spatialNavigate([centerScreenPoint.x, centerScreenPoint.y], pixels, undefined)
|
|
21
|
+
return positioned[idx].id
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const getNavigatedId = (id, key, items) => {
|
|
25
|
+
if (!items.length) {
|
|
26
|
+
return id
|
|
27
|
+
}
|
|
28
|
+
if (key === 'Home') {
|
|
29
|
+
return items[0].id
|
|
30
|
+
}
|
|
31
|
+
if (key === 'End') {
|
|
32
|
+
return items[items.length - 1].id
|
|
33
|
+
}
|
|
34
|
+
const idx = items.findIndex(item => item.id === id)
|
|
35
|
+
if (key === 'ArrowDown') {
|
|
36
|
+
return idx === -1 ? items[0].id : items[Math.min(idx + 1, items.length - 1)].id
|
|
37
|
+
}
|
|
38
|
+
return idx === -1 ? items[items.length - 1].id : items[Math.max(idx - 1, 0)].id
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ARIA listbox entry priority: first selected → last active (if still in list) → nearest the
|
|
42
|
+
// map center → first item (last resort, e.g. no items carry a screen position at all)
|
|
43
|
+
const resolveEntryId = (items, lastActiveId, selectedIds, centerScreenPoint) => {
|
|
44
|
+
const firstSelected = items.find(item => selectedIds.includes(item.id))
|
|
45
|
+
if (firstSelected) {
|
|
46
|
+
return firstSelected.id
|
|
47
|
+
}
|
|
48
|
+
if (lastActiveId && items.some(item => item.id === lastActiveId)) {
|
|
49
|
+
return lastActiveId
|
|
50
|
+
}
|
|
51
|
+
return findNearestToCenter(items, centerScreenPoint) ?? items[0].id
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Roving tabindex: moves real focus to the option matching id (via data-id, avoiding
|
|
55
|
+
// querySelector's CSS-escaping issues with arbitrary ids). Flags isInternalFocusMoveRef around
|
|
56
|
+
// the .focus() call so onFocus/onBlur can tell this apart from a real widget entry/exit.
|
|
57
|
+
const focusOption = (listboxEl, id, isInternalFocusMoveRef) => {
|
|
58
|
+
const el = Array.from(listboxEl?.children ?? []).find(li => li.dataset.id === String(id))
|
|
59
|
+
if (!el) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
isInternalFocusMoveRef.current = true
|
|
63
|
+
el.focus({ preventScroll: true })
|
|
64
|
+
isInternalFocusMoveRef.current = false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Keeps local selectedIds in sync with interact:selectionchange, and mirrors
|
|
69
|
+
* MAP_SET_ACTIVE_ITEM back into React state so the roving tabindex position stays current.
|
|
70
|
+
*/
|
|
71
|
+
function useEventBusListeners ({ eventBus, lastActiveIdRef, setActiveItemId, setSelectedIds }) {
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!eventBus) {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
const handleSetActive = ({ id }) => {
|
|
77
|
+
if (id !== null) {
|
|
78
|
+
lastActiveIdRef.current = id
|
|
79
|
+
}
|
|
80
|
+
setActiveItemId(id)
|
|
81
|
+
}
|
|
82
|
+
const handleSelectionChange = ({ selectedFeatures = [], selectedMarkers = [] }) => {
|
|
83
|
+
setSelectedIds([...selectedFeatures.map(f => String(f.featureId)), ...selectedMarkers])
|
|
84
|
+
}
|
|
85
|
+
eventBus.on(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
|
|
86
|
+
eventBus.on('interact:selectionchange', handleSelectionChange)
|
|
87
|
+
return () => {
|
|
88
|
+
eventBus.off(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
|
|
89
|
+
eventBus.off('interact:selectionchange', handleSelectionChange)
|
|
90
|
+
}
|
|
91
|
+
}, [eventBus])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Re-picks the active item (ARIA priority order) when it drops out of the item list while
|
|
96
|
+
* focused — e.g. panned off screen — and moves real focus to it.
|
|
97
|
+
*/
|
|
98
|
+
function useItemsRevalidation ({ items, eventBus, isFocusedRef, spatialListRef, isInternalFocusMoveRef, lastActiveIdRef, activeItemIdRef, selectedIdsRef, setActiveItemId, centerScreenPoint }) {
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!isFocusedRef.current) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (!items.length) {
|
|
104
|
+
setActiveItemId(null)
|
|
105
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: null })
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (items.some(item => item.id === activeItemIdRef.current)) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const nextId = resolveEntryId(items, lastActiveIdRef.current, selectedIdsRef.current, centerScreenPoint)
|
|
112
|
+
lastActiveIdRef.current = nextId
|
|
113
|
+
setActiveItemId(nextId)
|
|
114
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: nextId })
|
|
115
|
+
focusOption(spatialListRef.current, nextId, isInternalFocusMoveRef)
|
|
116
|
+
}, [items]) // NOSONAR — eventBus/selectedIds consumed via refs to avoid spurious re-runs on selection change
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Attaches keydown/keyup listeners to the listbox element for ARIA keyboard navigation:
|
|
121
|
+
* - ArrowUp/ArrowDown — move the active item sequentially, moving real focus (roving tabindex)
|
|
122
|
+
* - Alt+Arrow (any of the four) — move the active item spatially, to whichever item is
|
|
123
|
+
* nearest in the pressed direction on screen, same roving-tabindex move as above
|
|
124
|
+
* - Home/End — jump the active item to the first/last option
|
|
125
|
+
* - Enter/Space — confirm selection, emitting MAP_SELECT_ITEM
|
|
126
|
+
* - Escape — return focus to the map viewport
|
|
127
|
+
*/
|
|
128
|
+
function useKeyboardNavigation ({ spatialListRef, viewportRef, items, eventBus, activeItemIdRef, lastActiveIdRef, setActiveItemId, isInternalFocusMoveRef, hints, currentHintRef }) {
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const listboxEl = spatialListRef.current
|
|
131
|
+
if (!listboxEl) {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
const moveTo = (newId) => {
|
|
135
|
+
lastActiveIdRef.current = newId
|
|
136
|
+
setActiveItemId(newId)
|
|
137
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: newId })
|
|
138
|
+
focusOption(listboxEl, newId, isInternalFocusMoveRef)
|
|
139
|
+
}
|
|
140
|
+
const handleEscape = () => {
|
|
141
|
+
if (currentHintRef.current) {
|
|
142
|
+
hints.dismiss()
|
|
143
|
+
} else {
|
|
144
|
+
viewportRef.current?.focus()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Same modifier as the map's own label navigation, but moves list items instead of labels
|
|
148
|
+
// while this listbox has focus — the keyup listener below stops it bubbling to that binding.
|
|
149
|
+
const handleSpatialMove = (event) => {
|
|
150
|
+
moveTo(findNearestItemInDirection(items, activeItemIdRef.current, event.key))
|
|
151
|
+
}
|
|
152
|
+
const handleKeyDown = (event) => {
|
|
153
|
+
if (event.key === 'Escape') {
|
|
154
|
+
event.preventDefault()
|
|
155
|
+
event.stopPropagation()
|
|
156
|
+
handleEscape()
|
|
157
|
+
} else if (event.altKey && ARROW_KEYS.has(event.key)) {
|
|
158
|
+
// preventDefault here (on keydown) suppresses the browser's own Alt+Arrow
|
|
159
|
+
// history-navigation default.
|
|
160
|
+
event.preventDefault()
|
|
161
|
+
event.stopPropagation()
|
|
162
|
+
handleSpatialMove(event)
|
|
163
|
+
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
|
|
164
|
+
event.preventDefault()
|
|
165
|
+
event.stopPropagation()
|
|
166
|
+
moveTo(getNavigatedId(activeItemIdRef.current, event.key, items))
|
|
167
|
+
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
168
|
+
event.preventDefault()
|
|
169
|
+
event.stopPropagation()
|
|
170
|
+
eventBus?.emit(EVENTS.MAP_SELECT_ITEM)
|
|
171
|
+
} else {
|
|
172
|
+
// No action
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Shadows global Alt+<key> shortcuts (src/utils/globalAltShortcuts.js) from bubbling to
|
|
176
|
+
// the app-wide handler while this listbox has focus.
|
|
177
|
+
const handleKeyUp = (event) => {
|
|
178
|
+
stopIfGlobalAltKey(event)
|
|
179
|
+
}
|
|
180
|
+
listboxEl.addEventListener('keydown', handleKeyDown)
|
|
181
|
+
listboxEl.addEventListener('keyup', handleKeyUp)
|
|
182
|
+
return () => {
|
|
183
|
+
listboxEl.removeEventListener('keydown', handleKeyDown)
|
|
184
|
+
listboxEl.removeEventListener('keyup', handleKeyUp)
|
|
185
|
+
}
|
|
186
|
+
}, [viewportRef, spatialListRef, items, eventBus])
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Returns focus to the viewport when the user interacts with the map via pointer while the
|
|
191
|
+
* listbox is focused — clears its focus ring without dropping focus to nowhere.
|
|
192
|
+
*/
|
|
193
|
+
function useMapInteractionBlur ({ viewportRef, spatialListRef, isFocusedRef }) {
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const el = viewportRef.current
|
|
196
|
+
if (!el) {
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
const handlePointerDown = (event) => {
|
|
200
|
+
if (isFocusedRef.current && !spatialListRef.current?.contains(event.target)) {
|
|
201
|
+
viewportRef.current?.focus()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
el.addEventListener('pointerdown', handlePointerDown)
|
|
205
|
+
return () => { el.removeEventListener('pointerdown', handlePointerDown) }
|
|
206
|
+
}, [viewportRef, spatialListRef])
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Manages roving-tabindex focus state for the keyboard-accessible feature list. On focus, sets
|
|
211
|
+
* activeItemId via ARIA priority order (see resolveEntryId); on blur, clears it. Revalidates
|
|
212
|
+
* when the item list changes (e.g. after a map pan) so it never points to a stale item.
|
|
213
|
+
*
|
|
214
|
+
* @param {{ viewportRef: React.RefObject, spatialListRef: React.RefObject, items: Array, eventBus: object, centerScreenPoint: {x: number, y: number}|null }} params
|
|
215
|
+
* @returns {{ activeItemId: string|null, tabbableId: string|null, selectedIds: string[], onFocus: Function, onBlur: Function, selectItem: Function }}
|
|
216
|
+
*/
|
|
217
|
+
export function useSpatialListFocus ({ viewportRef, spatialListRef, items = [], eventBus, hints, centerScreenPoint }) {
|
|
218
|
+
const [activeItemId, setActiveItemId] = useState(null)
|
|
219
|
+
const [selectedIds, setSelectedIds] = useState([])
|
|
220
|
+
|
|
221
|
+
const isFocusedRef = useRef(false)
|
|
222
|
+
const lastActiveIdRef = useRef(null) // preserved across blur; restores position on re-focus
|
|
223
|
+
const activeItemIdRef = useRef(null) // always-current for keydown closure
|
|
224
|
+
const selectedIdsRef = useRef([]) // always-current for items-change effect
|
|
225
|
+
const currentHintRef = useRef(null)
|
|
226
|
+
const isInternalFocusMoveRef = useRef(false) // true only while focusOption()'s own .focus() call is in flight
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
return hints.subscribe((hint) => {
|
|
230
|
+
currentHintRef.current = hint
|
|
231
|
+
})
|
|
232
|
+
}, [hints])
|
|
233
|
+
|
|
234
|
+
// Always-current mirrors for values read from event-listener closures (not React re-renders) —
|
|
235
|
+
// assigned directly during render, same convention as useVisibleGeometry.js's latestRef.
|
|
236
|
+
activeItemIdRef.current = activeItemId
|
|
237
|
+
selectedIdsRef.current = selectedIds
|
|
238
|
+
|
|
239
|
+
useEventBusListeners({ eventBus, lastActiveIdRef, setActiveItemId, setSelectedIds })
|
|
240
|
+
useItemsRevalidation({ items, eventBus, isFocusedRef, spatialListRef, isInternalFocusMoveRef, lastActiveIdRef, activeItemIdRef, selectedIdsRef, setActiveItemId, centerScreenPoint })
|
|
241
|
+
useKeyboardNavigation({ spatialListRef, viewportRef, items, eventBus, activeItemIdRef, lastActiveIdRef, setActiveItemId, isInternalFocusMoveRef, hints, currentHintRef })
|
|
242
|
+
useMapInteractionBlur({ viewportRef, spatialListRef, isFocusedRef })
|
|
243
|
+
|
|
244
|
+
// Resting roving-tabindex position — where Tab lands before the list has ever had real focus.
|
|
245
|
+
// Deliberately never selection-driven (unlike onFocus's own resolution below): sticks to the
|
|
246
|
+
// last established keyboard position so an unrelated selection change elsewhere doesn't
|
|
247
|
+
// relocate it.
|
|
248
|
+
let tabbableId = null
|
|
249
|
+
if (items.length) {
|
|
250
|
+
const hasEstablishedPosition = lastActiveIdRef.current && items.some(item => item.id === lastActiveIdRef.current)
|
|
251
|
+
tabbableId = hasEstablishedPosition ? lastActiveIdRef.current : (findNearestToCenter(items, centerScreenPoint) ?? items[0].id)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const onFocus = () => {
|
|
255
|
+
isFocusedRef.current = true
|
|
256
|
+
// Ignore focus moving between sibling options (roving tabindex) — the keydown/selectItem
|
|
257
|
+
// path that moved it already resolved and emitted the correct id; re-resolving here would
|
|
258
|
+
// clobber that emit.
|
|
259
|
+
if (isInternalFocusMoveRef.current) {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
if (!items.length) {
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
// A real Tab-in prefers a selected item over the remembered position (native listbox
|
|
266
|
+
// behaviour) — unlike tabbableId, which deliberately ignores selection.
|
|
267
|
+
const id = resolveEntryId(items, lastActiveIdRef.current, selectedIds, centerScreenPoint)
|
|
268
|
+
lastActiveIdRef.current = id
|
|
269
|
+
setActiveItemId(id)
|
|
270
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id })
|
|
271
|
+
// Real focus may have landed on a different option than this resolves to (tabbableId and
|
|
272
|
+
// resolveEntryId can legitimately disagree) — move it to match so it never disagrees with activeItemId.
|
|
273
|
+
focusOption(spatialListRef.current, id, isInternalFocusMoveRef)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const onBlur = () => {
|
|
277
|
+
// Ignore focus moving between sibling options (roving tabindex) — not a real exit.
|
|
278
|
+
if (isInternalFocusMoveRef.current) {
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
isFocusedRef.current = false
|
|
282
|
+
setActiveItemId(null)
|
|
283
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: null })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Mirrors keyboard Enter/Space: make the clicked item active, focus it, then confirm selection.
|
|
287
|
+
const selectItem = (id) => {
|
|
288
|
+
lastActiveIdRef.current = id
|
|
289
|
+
setActiveItemId(id)
|
|
290
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id })
|
|
291
|
+
focusOption(spatialListRef.current, id, isInternalFocusMoveRef)
|
|
292
|
+
eventBus?.emit(EVENTS.MAP_SELECT_ITEM)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { activeItemId, tabbableId, selectedIds, onFocus, onBlur, selectItem }
|
|
296
|
+
}
|