@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
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useRef } from 'react'
|
|
2
|
-
import { EVENTS } from '../../config/events.js'
|
|
3
|
-
|
|
4
|
-
const getNavigatedId = (id, key, items) => {
|
|
5
|
-
if (!items.length) {
|
|
6
|
-
return id
|
|
7
|
-
}
|
|
8
|
-
if (key === 'Home') {
|
|
9
|
-
return items[0].id
|
|
10
|
-
}
|
|
11
|
-
if (key === 'End') {
|
|
12
|
-
return items[items.length - 1].id
|
|
13
|
-
}
|
|
14
|
-
const idx = items.findIndex(item => item.id === id)
|
|
15
|
-
if (key === 'ArrowDown') {
|
|
16
|
-
return idx === -1 ? items[0].id : items[Math.min(idx + 1, items.length - 1)].id
|
|
17
|
-
}
|
|
18
|
-
return idx === -1 ? items[items.length - 1].id : items[Math.max(idx - 1, 0)].id
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// ARIA listbox entry priority: first selected → last active (if still in list) → first item
|
|
22
|
-
const resolveEntryId = (items, lastActiveId, selectedIds) => {
|
|
23
|
-
const firstSelected = items.find(item => selectedIds.includes(item.id))
|
|
24
|
-
if (firstSelected) {
|
|
25
|
-
return firstSelected.id
|
|
26
|
-
}
|
|
27
|
-
if (lastActiveId && items.some(item => item.id === lastActiveId)) {
|
|
28
|
-
return lastActiveId
|
|
29
|
-
}
|
|
30
|
-
return items[0].id
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Roving tabindex: moves real focus to the option matching id, found via data-id (avoids
|
|
34
|
-
// CSS-escaping issues a querySelector would have with arbitrary ids). Flags
|
|
35
|
-
// isInternalFocusMoveRef around the .focus() call so onFocus/onBlur can ignore this as an
|
|
36
|
-
// internal move, not a real widget entry/exit — event.relatedTarget can't be used for that:
|
|
37
|
-
// preact/compat (react is aliased to it in this app's build) doesn't populate it on synthetic
|
|
38
|
-
// focus/blur events, so it's silently unreliable outside tests (real React + jsdom).
|
|
39
|
-
const focusOption = (listboxEl, id, isInternalFocusMoveRef) => {
|
|
40
|
-
const el = Array.from(listboxEl?.children ?? []).find(li => li.dataset.id === String(id))
|
|
41
|
-
if (!el) {
|
|
42
|
-
return
|
|
43
|
-
}
|
|
44
|
-
isInternalFocusMoveRef.current = true
|
|
45
|
-
el.focus({ preventScroll: true })
|
|
46
|
-
isInternalFocusMoveRef.current = false
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Keeps local selectedIds in sync with interact:selectionchange, and mirrors
|
|
51
|
-
* MAP_SET_ACTIVE_FEATURE back into React state so the roving tabindex position stays current.
|
|
52
|
-
*/
|
|
53
|
-
function useEventBusListeners ({ eventBus, lastActiveIdRef, setActiveFeatureId, setSelectedIds }) {
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (!eventBus) {
|
|
56
|
-
return undefined
|
|
57
|
-
}
|
|
58
|
-
const handleSetActive = ({ id }) => {
|
|
59
|
-
if (id !== null) {
|
|
60
|
-
lastActiveIdRef.current = id
|
|
61
|
-
}
|
|
62
|
-
setActiveFeatureId(id)
|
|
63
|
-
}
|
|
64
|
-
const handleSelectionChange = ({ selectedFeatures = [], selectedMarkers = [] }) => {
|
|
65
|
-
setSelectedIds([...selectedFeatures.map(f => String(f.featureId)), ...selectedMarkers])
|
|
66
|
-
}
|
|
67
|
-
eventBus.on(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
|
|
68
|
-
eventBus.on('interact:selectionchange', handleSelectionChange)
|
|
69
|
-
return () => {
|
|
70
|
-
eventBus.off(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
|
|
71
|
-
eventBus.off('interact:selectionchange', handleSelectionChange)
|
|
72
|
-
}
|
|
73
|
-
}, [eventBus])
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Re-picks the active item (ARIA priority order) when it drops out of the item list while
|
|
78
|
-
* focused — e.g. panned off screen — and moves real focus to it.
|
|
79
|
-
*/
|
|
80
|
-
function useItemsRevalidation ({ items, eventBus, isFocusedRef, featuresRef, isInternalFocusMoveRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId }) {
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (!isFocusedRef.current) {
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
if (!items.length) {
|
|
86
|
-
setActiveFeatureId(null)
|
|
87
|
-
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: null })
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
if (items.some(item => item.id === activeFeatureIdRef.current)) {
|
|
91
|
-
return
|
|
92
|
-
}
|
|
93
|
-
const nextId = resolveEntryId(items, lastActiveIdRef.current, selectedIdsRef.current)
|
|
94
|
-
lastActiveIdRef.current = nextId
|
|
95
|
-
setActiveFeatureId(nextId)
|
|
96
|
-
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: nextId })
|
|
97
|
-
focusOption(featuresRef.current, nextId, isInternalFocusMoveRef)
|
|
98
|
-
}, [items]) // NOSONAR — eventBus/selectedIds consumed via refs to avoid spurious re-runs on selection change
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Attaches a keydown listener to the listbox element for ARIA keyboard navigation:
|
|
103
|
-
* - ArrowUp/ArrowDown — move the active item, moving real focus to it (roving tabindex)
|
|
104
|
-
* - Home/End — jump the active item to the first/last option
|
|
105
|
-
* - Enter/Space — confirm selection, emitting MAP_SELECT_FEATURE
|
|
106
|
-
* - Escape — return focus to the map viewport
|
|
107
|
-
*/
|
|
108
|
-
function useKeyboardNavigation ({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, isInternalFocusMoveRef, hints, currentHintRef }) {
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
const listboxEl = featuresRef.current
|
|
111
|
-
if (!listboxEl) {
|
|
112
|
-
return undefined
|
|
113
|
-
}
|
|
114
|
-
const handleKeyDown = (event) => {
|
|
115
|
-
if (event.key === 'Escape') {
|
|
116
|
-
event.preventDefault()
|
|
117
|
-
event.stopPropagation()
|
|
118
|
-
if (currentHintRef.current) {
|
|
119
|
-
hints.dismiss()
|
|
120
|
-
} else {
|
|
121
|
-
viewportRef.current?.focus()
|
|
122
|
-
}
|
|
123
|
-
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
|
|
124
|
-
event.preventDefault()
|
|
125
|
-
event.stopPropagation()
|
|
126
|
-
const newId = getNavigatedId(activeFeatureIdRef.current, event.key, items)
|
|
127
|
-
lastActiveIdRef.current = newId
|
|
128
|
-
setActiveFeatureId(newId)
|
|
129
|
-
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: newId })
|
|
130
|
-
focusOption(listboxEl, newId, isInternalFocusMoveRef)
|
|
131
|
-
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
132
|
-
event.preventDefault()
|
|
133
|
-
event.stopPropagation()
|
|
134
|
-
eventBus?.emit(EVENTS.MAP_SELECT_FEATURE)
|
|
135
|
-
} else {
|
|
136
|
-
// No action
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
listboxEl.addEventListener('keydown', handleKeyDown)
|
|
140
|
-
return () => { listboxEl.removeEventListener('keydown', handleKeyDown) }
|
|
141
|
-
}, [viewportRef, featuresRef, items, eventBus])
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Returns focus to the viewport when the user interacts with the map via pointer while the
|
|
146
|
-
* listbox is focused — clears its focus ring without dropping focus to nowhere.
|
|
147
|
-
*/
|
|
148
|
-
function useMapInteractionBlur ({ viewportRef, featuresRef, isFocusedRef }) {
|
|
149
|
-
useEffect(() => {
|
|
150
|
-
const el = viewportRef.current
|
|
151
|
-
if (!el) {
|
|
152
|
-
return undefined
|
|
153
|
-
}
|
|
154
|
-
const handlePointerDown = (event) => {
|
|
155
|
-
if (isFocusedRef.current && !featuresRef.current?.contains(event.target)) {
|
|
156
|
-
viewportRef.current?.focus()
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
el.addEventListener('pointerdown', handlePointerDown)
|
|
160
|
-
return () => { el.removeEventListener('pointerdown', handlePointerDown) }
|
|
161
|
-
}, [viewportRef, featuresRef])
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Manages roving-tabindex focus state for the keyboard-accessible feature list. On focus, sets
|
|
166
|
-
* activeFeatureId via ARIA priority order (see resolveEntryId); on blur, clears it. Revalidates
|
|
167
|
-
* when the item list changes (e.g. after a map pan) so it never points to a stale item.
|
|
168
|
-
*
|
|
169
|
-
* @param {{ viewportRef: React.RefObject, featuresRef: React.RefObject, items: Array, eventBus: object }} params
|
|
170
|
-
* @returns {{ activeFeatureId: string|null, tabbableId: string|null, selectedIds: string[], onFocus: Function, onBlur: Function, selectItem: Function }}
|
|
171
|
-
*/
|
|
172
|
-
export function useFeatureFocus ({ viewportRef, featuresRef, items = [], eventBus, hints }) {
|
|
173
|
-
const [activeFeatureId, setActiveFeatureId] = useState(null)
|
|
174
|
-
const [selectedIds, setSelectedIds] = useState([])
|
|
175
|
-
|
|
176
|
-
const isFocusedRef = useRef(false)
|
|
177
|
-
const lastActiveIdRef = useRef(null) // preserved across blur; restores position on re-focus
|
|
178
|
-
const activeFeatureIdRef = useRef(null) // always-current for keydown closure
|
|
179
|
-
const selectedIdsRef = useRef([]) // always-current for items-change effect
|
|
180
|
-
const currentHintRef = useRef(null)
|
|
181
|
-
const isInternalFocusMoveRef = useRef(false) // true only while focusOption()'s own .focus() call is in flight
|
|
182
|
-
|
|
183
|
-
useEffect(() => {
|
|
184
|
-
return hints.subscribe((hint) => {
|
|
185
|
-
currentHintRef.current = hint
|
|
186
|
-
})
|
|
187
|
-
}, [hints])
|
|
188
|
-
|
|
189
|
-
// Always-current mirrors for values read from event-listener closures (not React re-renders) —
|
|
190
|
-
// assigned directly during render, same convention as useVisibleGeometry.js's latestRef.
|
|
191
|
-
activeFeatureIdRef.current = activeFeatureId
|
|
192
|
-
selectedIdsRef.current = selectedIds
|
|
193
|
-
|
|
194
|
-
useEventBusListeners({ eventBus, lastActiveIdRef, setActiveFeatureId, setSelectedIds })
|
|
195
|
-
useItemsRevalidation({ items, eventBus, isFocusedRef, featuresRef, isInternalFocusMoveRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId })
|
|
196
|
-
useKeyboardNavigation({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, isInternalFocusMoveRef, hints, currentHintRef })
|
|
197
|
-
useMapInteractionBlur({ viewportRef, featuresRef, isFocusedRef })
|
|
198
|
-
|
|
199
|
-
// Resting roving-tabindex position — where Tab lands before the list has ever had real focus.
|
|
200
|
-
// activeFeatureId takes priority once the list is actually focused. Deliberately never
|
|
201
|
-
// selection-driven (unlike onFocus's own resolution below) — sticks to the last established
|
|
202
|
-
// keyboard position, or the first item if there isn't one yet. "Prefer the selected item" is
|
|
203
|
-
// an onFocus-only concern (a real Tab-in); if it applied here too, any selection change made
|
|
204
|
-
// elsewhere (e.g. clicking a marker on the map, which never touches lastActiveIdRef) would
|
|
205
|
-
// keep relocating tabIndex to follow it, even though nothing about keyboard state changed.
|
|
206
|
-
let tabbableId = null
|
|
207
|
-
if (items.length) {
|
|
208
|
-
const hasEstablishedPosition = lastActiveIdRef.current && items.some(item => item.id === lastActiveIdRef.current)
|
|
209
|
-
tabbableId = hasEstablishedPosition ? lastActiveIdRef.current : items[0].id
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const onFocus = () => {
|
|
213
|
-
isFocusedRef.current = true
|
|
214
|
-
// Ignore focus moving between sibling options (roving tabindex) — the keydown/selectItem
|
|
215
|
-
// path that moved it already resolved and emitted the correct id; re-resolving here would
|
|
216
|
-
// clobber that emit.
|
|
217
|
-
if (isInternalFocusMoveRef.current) {
|
|
218
|
-
return
|
|
219
|
-
}
|
|
220
|
-
if (!items.length) {
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
// A real Tab-in always prefers a selected item over the remembered position (native listbox
|
|
224
|
-
// behaviour) — can't reuse tabbableId here, which deliberately does the opposite (sticks to
|
|
225
|
-
// the remembered position, ignoring selection) once one is established.
|
|
226
|
-
const id = resolveEntryId(items, lastActiveIdRef.current, selectedIds)
|
|
227
|
-
lastActiveIdRef.current = id
|
|
228
|
-
setActiveFeatureId(id)
|
|
229
|
-
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id })
|
|
230
|
-
// Real focus may have landed on a different option than this resolves to — tabbableId (which
|
|
231
|
-
// decided where Tab lands) can legitimately disagree with resolveEntryId's own priority (e.g.
|
|
232
|
-
// Tab lands on the structural first item, but a different item is selected and takes
|
|
233
|
-
// priority here). Move real focus to match so it never disagrees with activeFeatureId.
|
|
234
|
-
focusOption(featuresRef.current, id, isInternalFocusMoveRef)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const onBlur = () => {
|
|
238
|
-
// Ignore focus moving between sibling options (roving tabindex) — not a real exit.
|
|
239
|
-
if (isInternalFocusMoveRef.current) {
|
|
240
|
-
return
|
|
241
|
-
}
|
|
242
|
-
isFocusedRef.current = false
|
|
243
|
-
setActiveFeatureId(null)
|
|
244
|
-
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: null })
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Mirrors keyboard Enter/Space: make the clicked item active, focus it, then confirm selection.
|
|
248
|
-
const selectItem = (id) => {
|
|
249
|
-
lastActiveIdRef.current = id
|
|
250
|
-
setActiveFeatureId(id)
|
|
251
|
-
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id })
|
|
252
|
-
focusOption(featuresRef.current, id, isInternalFocusMoveRef)
|
|
253
|
-
eventBus?.emit(EVENTS.MAP_SELECT_FEATURE)
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return { activeFeatureId, tabbableId, selectedIds, onFocus, onBlur, selectItem }
|
|
257
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react'
|
|
2
|
-
|
|
3
|
-
const SET_FEATURES = 'map:setfeatures'
|
|
4
|
-
const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Subscribes to map:setfeatures for the listbox item list, and map:setfeaturessuppressed
|
|
8
|
-
* (any plugin can emit this) to report items as empty while suppressed regardless of what's
|
|
9
|
-
* actually visible — which is what drives <Features>'s own tabIndex/aria-hidden.
|
|
10
|
-
*
|
|
11
|
-
* @param {object} eventBus
|
|
12
|
-
* @returns {{ items: Array<{ id: string, label: string, x?: number, y?: number }>, multiselectable: boolean }}
|
|
13
|
-
*/
|
|
14
|
-
export function useFeatureItems (eventBus) {
|
|
15
|
-
const [items, setItems] = useState([])
|
|
16
|
-
const [multiselectable, setMultiselectable] = useState(false)
|
|
17
|
-
const [suppressed, setSuppressed] = useState(false)
|
|
18
|
-
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
if (!eventBus) {
|
|
21
|
-
return undefined
|
|
22
|
-
}
|
|
23
|
-
const handleSetFeatures = ({ items: next = [], multiselectable: nextMultiselectable = false }) => {
|
|
24
|
-
setItems(next)
|
|
25
|
-
setMultiselectable(nextMultiselectable)
|
|
26
|
-
}
|
|
27
|
-
const handleSuppressed = ({ suppressed: next = false } = {}) => {
|
|
28
|
-
setSuppressed(next)
|
|
29
|
-
}
|
|
30
|
-
eventBus.on(SET_FEATURES, handleSetFeatures)
|
|
31
|
-
eventBus.on(SET_FEATURES_SUPPRESSED, handleSuppressed)
|
|
32
|
-
return () => {
|
|
33
|
-
eventBus.off(SET_FEATURES, handleSetFeatures)
|
|
34
|
-
eventBus.off(SET_FEATURES_SUPPRESSED, handleSuppressed)
|
|
35
|
-
}
|
|
36
|
-
}, [eventBus])
|
|
37
|
-
|
|
38
|
-
return { items: suppressed ? [] : items, multiselectable }
|
|
39
|
-
}
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { renderHook, act } from '@testing-library/react'
|
|
2
|
-
import { useFeatureItems } from './useFeatureItems.js'
|
|
3
|
-
|
|
4
|
-
const SET_FEATURES = 'map:setfeatures' // NOSONAR
|
|
5
|
-
const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed' // NOSONAR
|
|
6
|
-
|
|
7
|
-
const makeEventBus = () => {
|
|
8
|
-
const listeners = {}
|
|
9
|
-
return {
|
|
10
|
-
on: jest.fn((e, fn) => { listeners[e] = fn }),
|
|
11
|
-
off: jest.fn(),
|
|
12
|
-
emit: (e, payload) => listeners[e]?.(payload)
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// ─── useFeatureItems — initial state ─────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
describe('useFeatureItems — initial state', () => {
|
|
19
|
-
it('returns empty items and multiselectable false before any event', () => {
|
|
20
|
-
const { result } = renderHook(() => useFeatureItems(makeEventBus()))
|
|
21
|
-
expect(result.current.items).toEqual([])
|
|
22
|
-
expect(result.current.multiselectable).toBe(false)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('returns empty items and multiselectable false when eventBus is undefined', () => {
|
|
26
|
-
const { result } = renderHook(() => useFeatureItems(undefined))
|
|
27
|
-
expect(result.current.items).toEqual([])
|
|
28
|
-
expect(result.current.multiselectable).toBe(false)
|
|
29
|
-
})
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
// ─── useFeatureItems — event subscription ────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
describe('useFeatureItems — event subscription', () => {
|
|
35
|
-
it('subscribes to map:setfeatures on mount', () => {
|
|
36
|
-
const eb = makeEventBus()
|
|
37
|
-
renderHook(() => useFeatureItems(eb))
|
|
38
|
-
expect(eb.on).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('subscribes to map:setfeaturessuppressed on mount', () => {
|
|
42
|
-
const eb = makeEventBus()
|
|
43
|
-
renderHook(() => useFeatureItems(eb))
|
|
44
|
-
expect(eb.on).toHaveBeenCalledWith(SET_FEATURES_SUPPRESSED, expect.any(Function))
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('unsubscribes on unmount', () => {
|
|
48
|
-
const eb = makeEventBus()
|
|
49
|
-
const { unmount } = renderHook(() => useFeatureItems(eb))
|
|
50
|
-
unmount()
|
|
51
|
-
expect(eb.off).toHaveBeenCalledWith(SET_FEATURES, expect.any(Function))
|
|
52
|
-
expect(eb.off).toHaveBeenCalledWith(SET_FEATURES_SUPPRESSED, expect.any(Function))
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('does not subscribe when eventBus is undefined', () => {
|
|
56
|
-
const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
57
|
-
renderHook(() => useFeatureItems(undefined))
|
|
58
|
-
expect(spy).not.toHaveBeenCalled()
|
|
59
|
-
spy.mockRestore()
|
|
60
|
-
})
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
// ─── useFeatureItems — items updates ─────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
describe('useFeatureItems — items updates', () => {
|
|
66
|
-
it('updates items when map:setfeatures is emitted', () => {
|
|
67
|
-
const eb = makeEventBus()
|
|
68
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
69
|
-
const items = [{ id: 'a', label: 'Feature A' }, { id: 'b', label: 'Feature B' }]
|
|
70
|
-
act(() => eb.emit(SET_FEATURES, { items }))
|
|
71
|
-
expect(result.current.items).toEqual(items)
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('clears items when emitted with an empty array', () => {
|
|
75
|
-
const eb = makeEventBus()
|
|
76
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
77
|
-
act(() => eb.emit(SET_FEATURES, { items: [{ id: 'a', label: 'A' }] }))
|
|
78
|
-
act(() => eb.emit(SET_FEATURES, { items: [] }))
|
|
79
|
-
expect(result.current.items).toEqual([])
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('defaults items to empty array when items key is missing from payload', () => {
|
|
83
|
-
const eb = makeEventBus()
|
|
84
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
85
|
-
act(() => eb.emit(SET_FEATURES, {}))
|
|
86
|
-
expect(result.current.items).toEqual([])
|
|
87
|
-
})
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
// ─── useFeatureItems — multiselectable updates ───────────────────────────────
|
|
91
|
-
|
|
92
|
-
describe('useFeatureItems — multiselectable updates', () => {
|
|
93
|
-
it('sets multiselectable true when emitted with multiselectable: true', () => {
|
|
94
|
-
const eb = makeEventBus()
|
|
95
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
96
|
-
act(() => eb.emit(SET_FEATURES, { items: [], multiselectable: true }))
|
|
97
|
-
expect(result.current.multiselectable).toBe(true)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('defaults multiselectable to false when not present in payload', () => {
|
|
101
|
-
const eb = makeEventBus()
|
|
102
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
103
|
-
act(() => eb.emit(SET_FEATURES, { items: [] }))
|
|
104
|
-
expect(result.current.multiselectable).toBe(false)
|
|
105
|
-
})
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
// ─── useFeatureItems — suppression ───────────────────────────────────────────
|
|
109
|
-
|
|
110
|
-
describe('useFeatureItems — suppression', () => {
|
|
111
|
-
const items = [{ id: 'a', label: 'Feature A' }]
|
|
112
|
-
|
|
113
|
-
it('reports empty items while suppressed, regardless of what was last set', () => {
|
|
114
|
-
const eb = makeEventBus()
|
|
115
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
116
|
-
act(() => eb.emit(SET_FEATURES, { items }))
|
|
117
|
-
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
|
|
118
|
-
expect(result.current.items).toEqual([])
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('restores the last known items once suppression is lifted', () => {
|
|
122
|
-
const eb = makeEventBus()
|
|
123
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
124
|
-
act(() => eb.emit(SET_FEATURES, { items }))
|
|
125
|
-
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
|
|
126
|
-
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
|
|
127
|
-
expect(result.current.items).toEqual(items)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('defaults suppressed to false when the payload omits it', () => {
|
|
131
|
-
const eb = makeEventBus()
|
|
132
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
133
|
-
act(() => eb.emit(SET_FEATURES, { items }))
|
|
134
|
-
act(() => eb.emit(SET_FEATURES_SUPPRESSED, {}))
|
|
135
|
-
expect(result.current.items).toEqual(items)
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
it('defaults suppressed to false when the event carries no payload at all', () => {
|
|
139
|
-
const eb = makeEventBus()
|
|
140
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
141
|
-
act(() => eb.emit(SET_FEATURES, { items }))
|
|
142
|
-
act(() => eb.emit(SET_FEATURES_SUPPRESSED))
|
|
143
|
-
expect(result.current.items).toEqual(items)
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
it('a features update received while suppressed is still applied once restored', () => {
|
|
147
|
-
const eb = makeEventBus()
|
|
148
|
-
const { result } = renderHook(() => useFeatureItems(eb))
|
|
149
|
-
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
|
|
150
|
-
act(() => eb.emit(SET_FEATURES, { items }))
|
|
151
|
-
expect(result.current.items).toEqual([]) // still suppressed
|
|
152
|
-
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
|
|
153
|
-
expect(result.current.items).toEqual(items)
|
|
154
|
-
})
|
|
155
|
-
})
|