@defra/interactive-map 0.0.22-alpha → 0.0.23-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/button-definition.md +39 -0
- package/docs/api/context.md +4 -4
- package/docs/api/map-style-config.md +11 -2
- package/docs/api/marker-config.md +38 -7
- package/docs/api/panel-definition.md +7 -3
- package/docs/api/symbol-config.md +10 -26
- package/docs/api/symbol-registry.md +28 -13
- package/docs/api.md +24 -2
- package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
- package/docs/assets/images/toggle-marker-label.jpg +0 -0
- package/docs/examples/index.mdx +12 -0
- package/docs/examples/toggle-marker-label.mdx +89 -0
- package/docs/plugins/interact.md +4 -11
- package/docusaurus.config.cjs +2 -1
- package/package.json +8 -8
- package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
- package/plugins/beta/datasets/dist/css/index.css +2 -2
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
- package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
- package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
- package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
- package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
- package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
- package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
- package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
- package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
- package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
- 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/map-styles/dist/esm/im-map-styles-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/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/src/manifest.js +1 -0
- 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 +23 -55
- package/plugins/interact/src/InteractInit.test.js +58 -80
- package/plugins/interact/src/defaults.js +1 -3
- package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
- package/plugins/interact/src/hooks/useAttachEvents.test.js +65 -0
- package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
- package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
- package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
- package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
- package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
- package/plugins/interact/src/hooks/useMapItemList.js +187 -0
- package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
- package/plugins/interact/src/index.js +0 -2
- package/plugins/interact/src/index.test.js +0 -2
- package/plugins/interact/src/manifest.js +37 -6
- package/plugins/interact/src/reducer.js +8 -4
- package/plugins/interact/src/reducer.test.js +18 -3
- package/plugins/interact/src/utils/buildStylesMap.js +21 -10
- package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
- package/plugins/interact/src/utils/spatial.js +0 -15
- package/plugins/interact/src/utils/spatial.test.js +1 -13
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
- package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
- package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
- package/plugins/search/src/search.scss +1 -0
- package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
- package/providers/beta/esri/src/esriProvider.js +4 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/esm/index.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/index.js +1 -1
- package/providers/maplibre/src/index.test.js +9 -14
- package/providers/maplibre/src/mapEvents.js +11 -5
- package/providers/maplibre/src/mapEvents.test.js +14 -3
- package/providers/maplibre/src/maplibreProvider.js +18 -11
- package/providers/maplibre/src/maplibreProvider.test.js +43 -36
- package/providers/maplibre/src/utils/highlightFeatures.js +102 -57
- package/providers/maplibre/src/utils/highlightFeatures.test.js +162 -51
- package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
- package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
- package/providers/maplibre/src/utils/patternImages.js +10 -12
- package/providers/maplibre/src/utils/patternImages.test.js +145 -167
- package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
- package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
- package/providers/maplibre/src/utils/symbolImages.js +23 -86
- package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
- package/src/App/components/Actions/Actions.module.scss +1 -0
- package/src/App/components/Actions/Actions.test.jsx +17 -4
- package/src/App/components/Hints/Hints.jsx +52 -0
- package/src/App/components/Hints/Hints.module.scss +37 -0
- package/src/App/components/Hints/Hints.test.jsx +104 -0
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
- package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
- package/src/App/components/Markers/LabelMarker.jsx +14 -0
- package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
- package/src/App/components/Markers/Markers.jsx +44 -75
- package/src/App/components/Markers/Markers.module.scss +58 -0
- package/src/App/components/Markers/Markers.test.jsx +90 -113
- package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
- package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
- package/src/App/components/Markers/SymbolMarker.jsx +31 -0
- package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
- package/src/App/components/Panel/Panel.jsx +1 -1
- package/src/App/components/Panel/Panel.module.scss +1 -0
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
- package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
- package/src/App/components/Tabs/Tabs.jsx +68 -0
- package/src/App/components/Tabs/Tabs.module.scss +57 -0
- package/src/App/components/Tabs/Tabs.test.jsx +121 -0
- package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
- package/src/App/components/Viewport/Features.jsx +35 -0
- package/src/App/components/Viewport/Features.module.scss +31 -0
- package/src/App/components/Viewport/Features.test.jsx +124 -0
- package/src/App/components/Viewport/MapStatus.jsx +0 -4
- package/src/App/components/Viewport/Viewport.jsx +35 -29
- package/src/App/components/Viewport/Viewport.module.scss +7 -21
- package/src/App/components/Viewport/Viewport.test.jsx +108 -65
- package/src/App/controls/keyboardActions.js +9 -3
- package/src/App/controls/keyboardActions.test.js +122 -87
- package/src/App/controls/keyboardShortcuts.js +1 -14
- package/src/App/hooks/useFeatureFocus.js +199 -0
- package/src/App/hooks/useFeatureFocus.test.js +635 -0
- package/src/App/hooks/useFeatureItems.js +30 -0
- package/src/App/hooks/useFeatureItems.test.js +97 -0
- package/src/App/hooks/useFocusVisible.js +4 -7
- package/src/App/hooks/useFocusVisible.test.js +6 -6
- package/src/App/hooks/useKeyboardHint.js +9 -34
- package/src/App/hooks/useKeyboardHint.test.js +34 -84
- package/src/App/hooks/useKeyboardShortcuts.js +8 -5
- package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
- package/src/App/hooks/useLayoutMeasurements.js +10 -4
- package/src/App/hooks/useLayoutMeasurements.test.js +30 -0
- package/src/App/hooks/useMarkerCursor.js +33 -0
- package/src/App/hooks/useMarkerCursor.test.js +87 -0
- package/src/App/hooks/useMarkersAPI.js +32 -9
- package/src/App/hooks/useMarkersAPI.test.js +162 -94
- package/src/App/layout/Layout.jsx +2 -0
- package/src/App/layout/Layout.test.jsx +3 -0
- package/src/App/registry/keyboardShortcutRegistry.js +3 -2
- package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
- package/src/App/renderer/mapButtons.js +5 -1
- package/src/App/renderer/mapButtons.test.js +37 -1
- package/src/App/store/ServiceProvider.jsx +4 -1
- package/src/App/store/ServiceProvider.test.jsx +11 -0
- package/src/App/store/appActionsMap.js +5 -1
- package/src/App/store/appActionsMap.test.js +13 -0
- package/src/App/store/appReducer.js +1 -0
- package/src/App/store/mapActionsMap.js +2 -1
- package/src/InteractiveMap/InteractiveMap.js +12 -0
- package/src/InteractiveMap/InteractiveMap.test.js +2 -0
- package/src/config/appConfig.js +3 -1
- package/src/config/appConfig.test.js +4 -0
- package/src/config/defaults.js +1 -1
- package/src/config/events.js +8 -0
- package/src/config/mapTheme.js +8 -4
- package/src/config/symbolConfig.js +23 -16
- package/src/scss/main.scss +3 -0
- package/src/scss/tools/_border-focus.scss +0 -1
- package/src/services/eventBus.js +15 -0
- package/src/services/eventBus.test.js +11 -0
- package/src/services/hints.js +47 -0
- package/src/services/hints.test.js +110 -0
- package/src/services/patternRegistry.js +69 -1
- package/src/services/patternRegistry.test.js +112 -1
- package/src/services/reverseGeocode.js +4 -0
- package/src/services/reverseGeocode.test.js +11 -1
- package/src/services/symbolRegistry.js +152 -17
- package/src/services/symbolRegistry.test.js +141 -31
- package/src/types.js +71 -14
- package/src/utils/constrainKeyboardFocus.js +5 -5
- package/src/utils/detectInterfaceType.js +27 -5
- package/src/utils/detectInterfaceType.test.js +29 -3
- package/src/utils/patternUtils.js +4 -67
- package/src/utils/patternUtils.test.js +1 -123
- package/src/utils/symbolUtils.js +11 -13
- package/src/utils/symbolUtils.test.js +18 -26
- package/plugins/interact/dist/css/index.css +0 -1
- package/plugins/interact/src/interact.scss +0 -24
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { useFeatureFocus } from './useFeatureFocus.js'
|
|
3
|
+
|
|
4
|
+
const ITEMS = [
|
|
5
|
+
{ id: 'a', label: 'Feature A' },
|
|
6
|
+
{ id: 'b', label: 'Feature B' },
|
|
7
|
+
{ id: 'c', label: 'Feature C' }
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
const SET_ACTIVE = 'map:setactivefeature' // NOSONAR
|
|
11
|
+
const SELECT = 'map:selectfeature'
|
|
12
|
+
const SELECTION_CHANGE = 'interact:selectionchange'
|
|
13
|
+
|
|
14
|
+
const makeEventBus = () => {
|
|
15
|
+
const listeners = {}
|
|
16
|
+
return {
|
|
17
|
+
on: jest.fn((e, fn) => { listeners[e] = fn }),
|
|
18
|
+
off: jest.fn(),
|
|
19
|
+
emit: jest.fn(),
|
|
20
|
+
trigger: (e, payload) => listeners[e]?.(payload)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const makeRefs = ({ viewportFocus } = {}) => {
|
|
25
|
+
const viewportEl = document.createElement('div')
|
|
26
|
+
viewportEl.focus = viewportFocus ?? jest.fn()
|
|
27
|
+
return {
|
|
28
|
+
viewportRef: { current: viewportEl },
|
|
29
|
+
featuresRef: { current: document.createElement('ul') },
|
|
30
|
+
hints: { subscribe: jest.fn(() => jest.fn()), dismiss: jest.fn() }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fireKey = (el, key) => {
|
|
35
|
+
let event
|
|
36
|
+
act(() => {
|
|
37
|
+
event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
|
38
|
+
el.dispatchEvent(event)
|
|
39
|
+
})
|
|
40
|
+
return event
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── useFeatureFocus — initial state ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe('useFeatureFocus — initial state', () => {
|
|
46
|
+
it('activeFeatureId starts as null', () => {
|
|
47
|
+
const { result } = renderHook(() => useFeatureFocus(makeRefs()))
|
|
48
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('exposes onFocus function', () => {
|
|
52
|
+
const { result } = renderHook(() => useFeatureFocus(makeRefs()))
|
|
53
|
+
expect(typeof result.current.onFocus).toBe('function')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('exposes onBlur function', () => {
|
|
57
|
+
const { result } = renderHook(() => useFeatureFocus(makeRefs()))
|
|
58
|
+
expect(typeof result.current.onBlur).toBe('function')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ─── useFeatureFocus — onFocus ────────────────────────────────────────────────
|
|
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 }))
|
|
67
|
+
act(() => result.current.onFocus())
|
|
68
|
+
expect(result.current.activeFeatureId).toBe('a')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('does nothing when items is empty', () => {
|
|
72
|
+
const { result } = renderHook(() => useFeatureFocus(makeRefs()))
|
|
73
|
+
act(() => result.current.onFocus())
|
|
74
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('sets active to first selected item (highest priority)', () => {
|
|
78
|
+
const eb = makeEventBus()
|
|
79
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
80
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'b', layerId: 'roads' }] }))
|
|
81
|
+
act(() => result.current.onFocus())
|
|
82
|
+
expect(result.current.activeFeatureId).toBe('b')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('selected item takes priority over last active position', () => {
|
|
86
|
+
const eb = makeEventBus()
|
|
87
|
+
const refs = makeRefs()
|
|
88
|
+
const el = refs.featuresRef.current
|
|
89
|
+
document.body.appendChild(el)
|
|
90
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
|
|
91
|
+
act(() => result.current.onFocus())
|
|
92
|
+
fireKey(el, 'ArrowDown') // last active = 'b'
|
|
93
|
+
act(() => result.current.onBlur())
|
|
94
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'c', layerId: 'roads' }] }))
|
|
95
|
+
act(() => result.current.onFocus())
|
|
96
|
+
expect(result.current.activeFeatureId).toBe('c') // selected beats last-active
|
|
97
|
+
unmount(); el.remove()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('restores last active position when nothing is selected', () => {
|
|
101
|
+
const refs = makeRefs()
|
|
102
|
+
const el = refs.featuresRef.current
|
|
103
|
+
document.body.appendChild(el)
|
|
104
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
105
|
+
act(() => result.current.onFocus())
|
|
106
|
+
fireKey(el, 'ArrowDown') // last active = 'b'
|
|
107
|
+
act(() => result.current.onBlur())
|
|
108
|
+
act(() => result.current.onFocus())
|
|
109
|
+
expect(result.current.activeFeatureId).toBe('b')
|
|
110
|
+
unmount(); el.remove()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('falls back to first item when previous active is no longer in the list', () => {
|
|
114
|
+
const eb = makeEventBus()
|
|
115
|
+
const refs = makeRefs()
|
|
116
|
+
const el = refs.featuresRef.current
|
|
117
|
+
document.body.appendChild(el)
|
|
118
|
+
const { result, rerender, unmount } = renderHook(
|
|
119
|
+
({ items }) => useFeatureFocus({ ...refs, items, eventBus: eb }),
|
|
120
|
+
{ initialProps: { items: ITEMS } }
|
|
121
|
+
)
|
|
122
|
+
act(() => result.current.onFocus())
|
|
123
|
+
fireKey(el, 'ArrowDown') // active = 'b'
|
|
124
|
+
act(() => result.current.onBlur())
|
|
125
|
+
const NEW_ITEMS = [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }]
|
|
126
|
+
rerender({ items: NEW_ITEMS })
|
|
127
|
+
act(() => result.current.onFocus())
|
|
128
|
+
expect(result.current.activeFeatureId).toBe('x')
|
|
129
|
+
unmount(); el.remove()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ─── useFeatureFocus — onBlur ─────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe('useFeatureFocus — onBlur', () => {
|
|
136
|
+
it('clears activeFeatureId when focus leaves the listbox', () => {
|
|
137
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
|
|
138
|
+
act(() => result.current.onFocus())
|
|
139
|
+
expect(result.current.activeFeatureId).toBe('a')
|
|
140
|
+
act(() => result.current.onBlur())
|
|
141
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('emits map:setactivefeature with null on blur', () => {
|
|
145
|
+
const eb = makeEventBus()
|
|
146
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
147
|
+
act(() => result.current.onFocus())
|
|
148
|
+
eb.emit.mockClear()
|
|
149
|
+
act(() => result.current.onBlur())
|
|
150
|
+
expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: null })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('does not throw when eventBus is not provided', () => {
|
|
154
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
|
|
155
|
+
expect(() => act(() => result.current.onBlur())).not.toThrow()
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// ─── useFeatureFocus — keydown listener lifecycle ────────────────────────────
|
|
160
|
+
|
|
161
|
+
describe('useFeatureFocus — keydown listener lifecycle', () => {
|
|
162
|
+
it('does not attach listener when featuresRef.current is null', () => {
|
|
163
|
+
const refs = makeRefs()
|
|
164
|
+
refs.featuresRef = { current: null }
|
|
165
|
+
const spy = jest.spyOn(document.body, 'addEventListener')
|
|
166
|
+
renderHook(() => useFeatureFocus(refs))
|
|
167
|
+
expect(spy).not.toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
168
|
+
spy.mockRestore()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('attaches and removes keydown listener on the features element', () => {
|
|
172
|
+
const refs = makeRefs()
|
|
173
|
+
const el = refs.featuresRef.current
|
|
174
|
+
const addSpy = jest.spyOn(el, 'addEventListener')
|
|
175
|
+
const removeSpy = jest.spyOn(el, 'removeEventListener')
|
|
176
|
+
const { unmount } = renderHook(() => useFeatureFocus(refs))
|
|
177
|
+
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
178
|
+
unmount()
|
|
179
|
+
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ─── useFeatureFocus — unhandled keys ────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe('useFeatureFocus — unhandled keys', () => {
|
|
186
|
+
it('does nothing for keys that are not Escape or Arrow', () => {
|
|
187
|
+
const viewportFocus = jest.fn()
|
|
188
|
+
const refs = makeRefs({ viewportFocus })
|
|
189
|
+
const el = refs.featuresRef.current
|
|
190
|
+
document.body.appendChild(el)
|
|
191
|
+
const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
192
|
+
fireKey(el, 'Tab')
|
|
193
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
194
|
+
expect(viewportFocus).not.toHaveBeenCalled()
|
|
195
|
+
el.remove()
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// ─── useFeatureFocus — Escape key ────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
describe('useFeatureFocus — Escape key', () => {
|
|
202
|
+
it('dismisses hint on Escape when hint is visible', () => {
|
|
203
|
+
const refs = makeRefs()
|
|
204
|
+
refs.hints.subscribe.mockImplementation((fn) => {
|
|
205
|
+
fn({ html: 'test' })
|
|
206
|
+
return jest.fn()
|
|
207
|
+
})
|
|
208
|
+
const el = refs.featuresRef.current
|
|
209
|
+
document.body.appendChild(el)
|
|
210
|
+
const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
211
|
+
act(() => result.current.onFocus())
|
|
212
|
+
fireKey(el, 'Escape')
|
|
213
|
+
expect(refs.hints.dismiss).toHaveBeenCalled()
|
|
214
|
+
expect(refs.viewportRef.current.focus).not.toHaveBeenCalled()
|
|
215
|
+
el.remove()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('focuses the viewport on Escape when no hint is visible', () => {
|
|
219
|
+
const viewportFocus = jest.fn()
|
|
220
|
+
const refs = makeRefs({ viewportFocus })
|
|
221
|
+
const el = refs.featuresRef.current
|
|
222
|
+
document.body.appendChild(el)
|
|
223
|
+
const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
224
|
+
act(() => result.current.onFocus())
|
|
225
|
+
fireKey(el, 'Escape')
|
|
226
|
+
expect(refs.hints.dismiss).not.toHaveBeenCalled()
|
|
227
|
+
expect(viewportFocus).toHaveBeenCalled()
|
|
228
|
+
el.remove()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('does not throw when viewportRef.current is null', () => {
|
|
232
|
+
const refs = { viewportRef: { current: null }, featuresRef: { current: document.createElement('ul') }, hints: { subscribe: jest.fn(() => jest.fn()), dismiss: jest.fn() } }
|
|
233
|
+
const el = refs.featuresRef.current
|
|
234
|
+
document.body.appendChild(el)
|
|
235
|
+
renderHook(() => useFeatureFocus(refs))
|
|
236
|
+
expect(() => fireKey(el, 'Escape')).not.toThrow()
|
|
237
|
+
el.remove()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('stops propagation on Escape so the viewport keyboard handler does not also handle it', () => {
|
|
241
|
+
const refs = makeRefs()
|
|
242
|
+
const el = refs.featuresRef.current
|
|
243
|
+
document.body.appendChild(el)
|
|
244
|
+
renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
245
|
+
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
|
246
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
247
|
+
act(() => el.dispatchEvent(event))
|
|
248
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
249
|
+
el.remove()
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// ─── useFeatureFocus — ArrowDown navigation ───────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe('useFeatureFocus — ArrowDown navigation', () => {
|
|
256
|
+
const setup = () => {
|
|
257
|
+
const refs = makeRefs()
|
|
258
|
+
const el = refs.featuresRef.current
|
|
259
|
+
document.body.appendChild(el)
|
|
260
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
261
|
+
return { result, el, unmount }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
it('selects first item when no item is active', () => {
|
|
265
|
+
const { result, el, unmount } = setup()
|
|
266
|
+
fireKey(el, 'ArrowDown')
|
|
267
|
+
expect(result.current.activeFeatureId).toBe('a')
|
|
268
|
+
unmount(); el.remove()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('advances to next item', () => {
|
|
272
|
+
const { result, el, unmount } = setup()
|
|
273
|
+
act(() => result.current.onFocus())
|
|
274
|
+
fireKey(el, 'ArrowDown')
|
|
275
|
+
expect(result.current.activeFeatureId).toBe('b')
|
|
276
|
+
unmount(); el.remove()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('clamps at last item', () => {
|
|
280
|
+
const refs = makeRefs()
|
|
281
|
+
const el = refs.featuresRef.current
|
|
282
|
+
document.body.appendChild(el)
|
|
283
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
284
|
+
act(() => result.current.onFocus())
|
|
285
|
+
fireKey(el, 'ArrowDown')
|
|
286
|
+
fireKey(el, 'ArrowDown')
|
|
287
|
+
fireKey(el, 'ArrowDown')
|
|
288
|
+
expect(result.current.activeFeatureId).toBe('c')
|
|
289
|
+
unmount(); el.remove()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('does nothing when items is empty', () => {
|
|
293
|
+
const refs = makeRefs()
|
|
294
|
+
const el = refs.featuresRef.current
|
|
295
|
+
document.body.appendChild(el)
|
|
296
|
+
const { result, unmount } = renderHook(() => useFeatureFocus(refs))
|
|
297
|
+
fireKey(el, 'ArrowDown')
|
|
298
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
299
|
+
unmount(); el.remove()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('stops propagation on ArrowDown so the viewport keyboard handler does not pan the map', () => {
|
|
303
|
+
const { result, el, unmount } = setup()
|
|
304
|
+
act(() => result.current.onFocus())
|
|
305
|
+
const event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
|
|
306
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
307
|
+
act(() => el.dispatchEvent(event))
|
|
308
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
309
|
+
unmount(); el.remove()
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// ─── useFeatureFocus — ArrowUp navigation ────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
describe('useFeatureFocus — ArrowUp navigation', () => {
|
|
316
|
+
it('selects last item when no item is active', () => {
|
|
317
|
+
const refs = makeRefs()
|
|
318
|
+
const el = refs.featuresRef.current
|
|
319
|
+
document.body.appendChild(el)
|
|
320
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
321
|
+
fireKey(el, 'ArrowUp')
|
|
322
|
+
expect(result.current.activeFeatureId).toBe('c')
|
|
323
|
+
unmount(); el.remove()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('moves to previous item', () => {
|
|
327
|
+
const refs = makeRefs()
|
|
328
|
+
const el = refs.featuresRef.current
|
|
329
|
+
document.body.appendChild(el)
|
|
330
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
331
|
+
act(() => result.current.onFocus())
|
|
332
|
+
fireKey(el, 'ArrowDown')
|
|
333
|
+
fireKey(el, 'ArrowUp')
|
|
334
|
+
expect(result.current.activeFeatureId).toBe('a')
|
|
335
|
+
unmount(); el.remove()
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('clamps at first item', () => {
|
|
339
|
+
const refs = makeRefs()
|
|
340
|
+
const el = refs.featuresRef.current
|
|
341
|
+
document.body.appendChild(el)
|
|
342
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
343
|
+
act(() => result.current.onFocus())
|
|
344
|
+
fireKey(el, 'ArrowUp')
|
|
345
|
+
fireKey(el, 'ArrowUp')
|
|
346
|
+
expect(result.current.activeFeatureId).toBe('a')
|
|
347
|
+
unmount(); el.remove()
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// ─── useFeatureFocus — eventBus integration ───────────────────────────────────
|
|
352
|
+
|
|
353
|
+
describe('useFeatureFocus — eventBus: map:setactivefeature listener', () => {
|
|
354
|
+
it('subscribes to map:setactivefeature on mount and unsubscribes on unmount', () => {
|
|
355
|
+
const eb = makeEventBus()
|
|
356
|
+
const { unmount } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
|
|
357
|
+
expect(eb.on).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function))
|
|
358
|
+
unmount()
|
|
359
|
+
expect(eb.off).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function))
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('updates activeFeatureId when map:setactivefeature is received', () => {
|
|
363
|
+
const eb = makeEventBus()
|
|
364
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
|
|
365
|
+
act(() => eb.trigger(SET_ACTIVE, { id: 'a' }))
|
|
366
|
+
expect(result.current.activeFeatureId).toBe('a')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('clears activeFeatureId when map:setactivefeature is received with null', () => {
|
|
370
|
+
const eb = makeEventBus()
|
|
371
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
372
|
+
act(() => eb.trigger(SET_ACTIVE, { id: 'a' }))
|
|
373
|
+
act(() => eb.trigger(SET_ACTIVE, { id: null }))
|
|
374
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
const setupWithBus = () => {
|
|
379
|
+
const eb = makeEventBus()
|
|
380
|
+
const refs = makeRefs()
|
|
381
|
+
const el = refs.featuresRef.current
|
|
382
|
+
document.body.appendChild(el)
|
|
383
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
|
|
384
|
+
return { eb, el, result, unmount }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
describe('useFeatureFocus — eventBus: map:setactivefeature emit', () => {
|
|
388
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
389
|
+
|
|
390
|
+
it('emits map:setactivefeature with first item id on onFocus', () => {
|
|
391
|
+
const { eb, result } = setupWithBus()
|
|
392
|
+
act(() => result.current.onFocus())
|
|
393
|
+
expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'a' })
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('emits map:setactivefeature with new id on ArrowDown', () => {
|
|
397
|
+
const { eb, result, el, unmount } = setupWithBus()
|
|
398
|
+
act(() => result.current.onFocus())
|
|
399
|
+
eb.emit.mockClear()
|
|
400
|
+
fireKey(el, 'ArrowDown')
|
|
401
|
+
expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'b' })
|
|
402
|
+
unmount()
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('does not emit when eventBus is not provided', () => {
|
|
406
|
+
const refs = makeRefs()
|
|
407
|
+
const el = refs.featuresRef.current
|
|
408
|
+
document.body.appendChild(el)
|
|
409
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
410
|
+
expect(() => {
|
|
411
|
+
act(() => result.current.onFocus())
|
|
412
|
+
fireKey(el, 'ArrowDown')
|
|
413
|
+
}).not.toThrow()
|
|
414
|
+
unmount()
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// ─── useFeatureFocus — interact:selectionchange listener ─────────────────────
|
|
419
|
+
|
|
420
|
+
describe('useFeatureFocus — interact:selectionchange listener', () => {
|
|
421
|
+
it('subscribes to interact:selectionchange on mount and unsubscribes on unmount', () => {
|
|
422
|
+
const eb = makeEventBus()
|
|
423
|
+
const { unmount } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
|
|
424
|
+
expect(eb.on).toHaveBeenCalledWith(SELECTION_CHANGE, expect.any(Function))
|
|
425
|
+
unmount()
|
|
426
|
+
expect(eb.off).toHaveBeenCalledWith(SELECTION_CHANGE, expect.any(Function))
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('sets selectedIds from selected features', () => {
|
|
430
|
+
const eb = makeEventBus()
|
|
431
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
|
|
432
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 27665979, layerId: 'hedges' }] }))
|
|
433
|
+
expect(result.current.selectedIds).toEqual(['27665979'])
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('sets selectedIds from multiple selected features', () => {
|
|
437
|
+
const eb = makeEventBus()
|
|
438
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
|
|
439
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }, { featureId: 'f2', layerId: 'roads' }] }))
|
|
440
|
+
expect(result.current.selectedIds).toEqual(['f1', 'f2'])
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('sets selectedIds from selected markers', () => {
|
|
444
|
+
const eb = makeEventBus()
|
|
445
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
|
|
446
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['m1', 'm2'] }))
|
|
447
|
+
expect(result.current.selectedIds).toEqual(['m1', 'm2'])
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('sets selectedIds from both features and markers when both are selected', () => {
|
|
451
|
+
const eb = makeEventBus()
|
|
452
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
|
|
453
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }], selectedMarkers: ['m1'] }))
|
|
454
|
+
expect(result.current.selectedIds).toEqual(['f1', 'm1'])
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('clears selectedIds when selection becomes empty', () => {
|
|
458
|
+
const eb = makeEventBus()
|
|
459
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), eventBus: eb }))
|
|
460
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }] }))
|
|
461
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [], selectedMarkers: [] }))
|
|
462
|
+
expect(result.current.selectedIds).toEqual([])
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('does not move activeFeatureId when selectionchange fires (cursor is keyboard-only)', () => {
|
|
466
|
+
const eb = makeEventBus()
|
|
467
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
468
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'a', layerId: 'roads' }] }))
|
|
469
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
// ─── useFeatureFocus — map interaction returns focus to viewport ──────────────
|
|
474
|
+
|
|
475
|
+
describe('useFeatureFocus — map pointerdown returns focus to viewport', () => {
|
|
476
|
+
const pointerDown = (target) => act(() => {
|
|
477
|
+
target.dispatchEvent(new Event('pointerdown', { bubbles: true, cancelable: true }))
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('focuses the viewport when pointer lands on the map while listbox is focused', () => {
|
|
481
|
+
const viewportFocus = jest.fn()
|
|
482
|
+
const refs = makeRefs({ viewportFocus })
|
|
483
|
+
document.body.appendChild(refs.viewportRef.current)
|
|
484
|
+
const mapCanvas = document.createElement('canvas')
|
|
485
|
+
refs.viewportRef.current.appendChild(mapCanvas)
|
|
486
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
487
|
+
act(() => result.current.onFocus())
|
|
488
|
+
viewportFocus.mockClear()
|
|
489
|
+
pointerDown(mapCanvas)
|
|
490
|
+
expect(viewportFocus).toHaveBeenCalled()
|
|
491
|
+
unmount()
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('does not move focus when pointer lands inside the listbox', () => {
|
|
495
|
+
const viewportFocus = jest.fn()
|
|
496
|
+
const refs = makeRefs({ viewportFocus })
|
|
497
|
+
document.body.appendChild(refs.viewportRef.current)
|
|
498
|
+
refs.viewportRef.current.appendChild(refs.featuresRef.current)
|
|
499
|
+
const option = document.createElement('li')
|
|
500
|
+
refs.featuresRef.current.appendChild(option)
|
|
501
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
502
|
+
act(() => result.current.onFocus())
|
|
503
|
+
viewportFocus.mockClear()
|
|
504
|
+
pointerDown(option)
|
|
505
|
+
expect(viewportFocus).not.toHaveBeenCalled()
|
|
506
|
+
unmount()
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('does not move focus when pointer fires while listbox is not focused', () => {
|
|
510
|
+
const viewportFocus = jest.fn()
|
|
511
|
+
const refs = makeRefs({ viewportFocus })
|
|
512
|
+
document.body.appendChild(refs.viewportRef.current)
|
|
513
|
+
const mapCanvas = document.createElement('canvas')
|
|
514
|
+
refs.viewportRef.current.appendChild(mapCanvas)
|
|
515
|
+
const { unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
516
|
+
pointerDown(mapCanvas)
|
|
517
|
+
expect(viewportFocus).not.toHaveBeenCalled()
|
|
518
|
+
unmount()
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// ─── useFeatureFocus — Enter and Space keys ───────────────────────────────────
|
|
525
|
+
|
|
526
|
+
describe('useFeatureFocus — Enter and Space keys', () => {
|
|
527
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
528
|
+
|
|
529
|
+
it('emits map:selectfeature on Enter', () => {
|
|
530
|
+
const { eb, result, el, unmount } = setupWithBus()
|
|
531
|
+
act(() => result.current.onFocus())
|
|
532
|
+
eb.emit.mockClear()
|
|
533
|
+
fireKey(el, 'Enter')
|
|
534
|
+
expect(eb.emit).toHaveBeenCalledWith(SELECT)
|
|
535
|
+
unmount()
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('emits map:selectfeature on Space', () => {
|
|
539
|
+
const { eb, result, el, unmount } = setupWithBus()
|
|
540
|
+
act(() => result.current.onFocus())
|
|
541
|
+
eb.emit.mockClear()
|
|
542
|
+
fireKey(el, ' ')
|
|
543
|
+
expect(eb.emit).toHaveBeenCalledWith(SELECT)
|
|
544
|
+
unmount()
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('stops propagation on Enter', () => {
|
|
548
|
+
const { el, result, unmount } = setupWithBus()
|
|
549
|
+
act(() => result.current.onFocus())
|
|
550
|
+
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
|
551
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
552
|
+
act(() => el.dispatchEvent(event))
|
|
553
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
554
|
+
unmount()
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('stops propagation on Space', () => {
|
|
558
|
+
const { el, result, unmount } = setupWithBus()
|
|
559
|
+
act(() => result.current.onFocus())
|
|
560
|
+
const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
|
|
561
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
562
|
+
act(() => el.dispatchEvent(event))
|
|
563
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
564
|
+
unmount()
|
|
565
|
+
})
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
// ─── useFeatureFocus — items change while focused ─────────────────────────────
|
|
569
|
+
|
|
570
|
+
describe('useFeatureFocus — items change while focused', () => {
|
|
571
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
572
|
+
|
|
573
|
+
const setupRerender = (initialItems) => {
|
|
574
|
+
const eb = makeEventBus()
|
|
575
|
+
const refs = makeRefs()
|
|
576
|
+
document.body.appendChild(refs.featuresRef.current)
|
|
577
|
+
const { result, rerender, unmount } = renderHook(
|
|
578
|
+
({ items }) => useFeatureFocus({ ...refs, items, eventBus: eb }),
|
|
579
|
+
{ initialProps: { items: initialItems } }
|
|
580
|
+
)
|
|
581
|
+
return { eb, result, rerender, unmount }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
it('keeps current active item when it is still in the updated list', () => {
|
|
585
|
+
const { result, rerender, unmount } = setupRerender(ITEMS)
|
|
586
|
+
act(() => result.current.onFocus()) // active = 'a'
|
|
587
|
+
act(() => { rerender({ items: [{ id: 'a', label: 'A updated' }, { id: 'd', label: 'D' }] }) })
|
|
588
|
+
expect(result.current.activeFeatureId).toBe('a')
|
|
589
|
+
unmount()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('re-picks by priority when current active item leaves the list — first selected wins', () => {
|
|
593
|
+
const { eb, result, rerender, unmount } = setupRerender(ITEMS)
|
|
594
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'c', layerId: 'l' }] }))
|
|
595
|
+
act(() => result.current.onFocus()) // active = 'a' (first, no prev)
|
|
596
|
+
act(() => { rerender({ items: [{ id: 'c', label: 'C' }, { id: 'd', label: 'D' }] }) }) // 'a' gone
|
|
597
|
+
expect(result.current.activeFeatureId).toBe('c') // first selected
|
|
598
|
+
unmount()
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('re-picks first item when active leaves and no selected item is in the new list', () => {
|
|
602
|
+
const { result, rerender, unmount } = setupRerender(ITEMS)
|
|
603
|
+
act(() => result.current.onFocus()) // active = 'a'
|
|
604
|
+
act(() => { rerender({ items: [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }] }) })
|
|
605
|
+
expect(result.current.activeFeatureId).toBe('x')
|
|
606
|
+
unmount()
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
it('does not change active item when the listbox is not focused', () => {
|
|
610
|
+
const { result, rerender, unmount } = setupRerender(ITEMS)
|
|
611
|
+
// never called onFocus — isFocusedRef stays false
|
|
612
|
+
act(() => { rerender({ items: [{ id: 'x', label: 'X' }] }) })
|
|
613
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
614
|
+
unmount()
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('emits map:setactivefeature with new id when re-pick occurs', () => {
|
|
618
|
+
const { eb, result, rerender, unmount } = setupRerender(ITEMS)
|
|
619
|
+
act(() => result.current.onFocus())
|
|
620
|
+
eb.emit.mockClear()
|
|
621
|
+
act(() => { rerender({ items: [{ id: 'x', label: 'X' }] }) }) // 'a' gone
|
|
622
|
+
expect(eb.emit).toHaveBeenCalledWith('map:setactivefeature', { id: 'x' })
|
|
623
|
+
unmount()
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('clears active and emits null when items list becomes empty while focused', () => {
|
|
627
|
+
const { eb, result, rerender, unmount } = setupRerender(ITEMS)
|
|
628
|
+
act(() => result.current.onFocus())
|
|
629
|
+
eb.emit.mockClear()
|
|
630
|
+
act(() => { rerender({ items: [] }) })
|
|
631
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
632
|
+
expect(eb.emit).toHaveBeenCalledWith('map:setactivefeature', { id: null })
|
|
633
|
+
unmount()
|
|
634
|
+
})
|
|
635
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Subscribes to map:setfeatures and exposes the current listbox item list and multiselectable flag.
|
|
5
|
+
* The list is produced by useMapItemList in the interact plugin and pushed via the event bus
|
|
6
|
+
* whenever the visible set of markers or features changes.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} eventBus
|
|
9
|
+
* @returns {{ items: Array<{ id: string, label: string }>, multiselectable: boolean }}
|
|
10
|
+
*/
|
|
11
|
+
export function useFeatureItems (eventBus) {
|
|
12
|
+
const [items, setItems] = useState([])
|
|
13
|
+
const [multiselectable, setMultiselectable] = useState(false)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!eventBus) {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
const handle = ({ items: next = [], multiselectable: nextMultiselectable = false }) => {
|
|
20
|
+
setItems(next)
|
|
21
|
+
setMultiselectable(nextMultiselectable)
|
|
22
|
+
}
|
|
23
|
+
eventBus.on('map:setfeatures', handle)
|
|
24
|
+
return () => {
|
|
25
|
+
eventBus.off('map:setfeatures', handle)
|
|
26
|
+
}
|
|
27
|
+
}, [eventBus])
|
|
28
|
+
|
|
29
|
+
return { items, multiselectable }
|
|
30
|
+
}
|