@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,51 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
const SET_SPATIAL_LIST = 'map:setspatiallist'
|
|
4
|
+
const SET_SPATIAL_LIST_SUPPRESSED = 'map:setspatiallistsuppressed'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Subscribes to map:setspatiallist for the listbox item list, and
|
|
8
|
+
* map:setspatiallistsuppressed (any plugin can emit this) to report items as empty while
|
|
9
|
+
* suppressed regardless of what's actually visible — which is what drives <SpatialList>'s own
|
|
10
|
+
* tabIndex/aria-hidden.
|
|
11
|
+
*
|
|
12
|
+
* Items always carry at least { id, label, x?, y? } — SpatialList.jsx/useSpatialListFocus.js
|
|
13
|
+
* only ever read those. Whichever plugin currently owns the list (via spatialListRegistry —
|
|
14
|
+
* interact today, draw's edit-mode vertices in future) may include further fields of its own
|
|
15
|
+
* on each item; those simply ride along unused here. `label` is that same owner's own name for
|
|
16
|
+
* what the list currently represents (e.g. "Map features", or "Shape points" while draw holds
|
|
17
|
+
* an exclusive claim) — see spatialListRegistry.js.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} eventBus
|
|
20
|
+
* @returns {{ items: Array<{ id: string, label: string, x?: number, y?: number }>, multiselectable: boolean, label: string|undefined, focusable: boolean }}
|
|
21
|
+
*/
|
|
22
|
+
export function useSpatialListItems (eventBus) {
|
|
23
|
+
const [items, setItems] = useState([])
|
|
24
|
+
const [multiselectable, setMultiselectable] = useState(false)
|
|
25
|
+
const [label, setLabel] = useState(undefined)
|
|
26
|
+
const [focusable, setFocusable] = useState(true)
|
|
27
|
+
const [suppressed, setSuppressed] = useState(false)
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!eventBus) {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
const handleSetSpatialList = ({ items: next = [], multiselectable: nextMultiselectable = false, label: nextLabel, focusable: nextFocusable = true } = {}) => {
|
|
34
|
+
setItems(next)
|
|
35
|
+
setMultiselectable(nextMultiselectable)
|
|
36
|
+
setLabel(nextLabel)
|
|
37
|
+
setFocusable(nextFocusable)
|
|
38
|
+
}
|
|
39
|
+
const handleSuppressed = ({ suppressed: next = false } = {}) => {
|
|
40
|
+
setSuppressed(next)
|
|
41
|
+
}
|
|
42
|
+
eventBus.on(SET_SPATIAL_LIST, handleSetSpatialList)
|
|
43
|
+
eventBus.on(SET_SPATIAL_LIST_SUPPRESSED, handleSuppressed)
|
|
44
|
+
return () => {
|
|
45
|
+
eventBus.off(SET_SPATIAL_LIST, handleSetSpatialList)
|
|
46
|
+
eventBus.off(SET_SPATIAL_LIST_SUPPRESSED, handleSuppressed)
|
|
47
|
+
}
|
|
48
|
+
}, [eventBus])
|
|
49
|
+
|
|
50
|
+
return { items: suppressed ? [] : items, multiselectable, label, focusable }
|
|
51
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { useSpatialListItems } from './useSpatialListItems.js'
|
|
3
|
+
|
|
4
|
+
const SET_FEATURES = 'map:setspatiallist' // NOSONAR
|
|
5
|
+
const SET_FEATURES_SUPPRESSED = 'map:setspatiallistsuppressed' // NOSONAR
|
|
6
|
+
|
|
7
|
+
const makeEventBus = () => {
|
|
8
|
+
const listeners = {}
|
|
9
|
+
return {
|
|
10
|
+
on: jest.fn((eventName, fn) => { listeners[eventName] = fn }),
|
|
11
|
+
off: jest.fn(),
|
|
12
|
+
emit: (eventName, payload) => listeners[eventName]?.(payload)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── useSpatialListItems — initial state ─────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe('useSpatialListItems — initial state', () => {
|
|
19
|
+
it('returns empty items and multiselectable false before any event', () => {
|
|
20
|
+
const { result } = renderHook(() => useSpatialListItems(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(() => useSpatialListItems(undefined))
|
|
27
|
+
expect(result.current.items).toEqual([])
|
|
28
|
+
expect(result.current.multiselectable).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// ─── useSpatialListItems — event subscription ────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe('useSpatialListItems — event subscription', () => {
|
|
35
|
+
it('subscribes to map:setfeatures on mount', () => {
|
|
36
|
+
const eb = makeEventBus()
|
|
37
|
+
renderHook(() => useSpatialListItems(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(() => useSpatialListItems(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(() => useSpatialListItems(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(() => useSpatialListItems(undefined))
|
|
58
|
+
expect(spy).not.toHaveBeenCalled()
|
|
59
|
+
spy.mockRestore()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ─── useSpatialListItems — items updates ─────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe('useSpatialListItems — items updates', () => {
|
|
66
|
+
it('updates items when map:setfeatures is emitted', () => {
|
|
67
|
+
const eb = makeEventBus()
|
|
68
|
+
const { result } = renderHook(() => useSpatialListItems(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(() => useSpatialListItems(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(() => useSpatialListItems(eb))
|
|
85
|
+
act(() => eb.emit(SET_FEATURES, {}))
|
|
86
|
+
expect(result.current.items).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('defaults items to empty array when the event carries no payload at all', () => {
|
|
90
|
+
const eb = makeEventBus()
|
|
91
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
92
|
+
act(() => eb.emit(SET_FEATURES))
|
|
93
|
+
expect(result.current.items).toEqual([])
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// ─── useSpatialListItems — label updates ─────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe('useSpatialListItems — label updates', () => {
|
|
100
|
+
it('returns undefined label before any event', () => {
|
|
101
|
+
const { result } = renderHook(() => useSpatialListItems(makeEventBus()))
|
|
102
|
+
expect(result.current.label).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns whatever label the current provider declared', () => {
|
|
106
|
+
const eb = makeEventBus()
|
|
107
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
108
|
+
act(() => eb.emit(SET_FEATURES, { items: [], label: 'Shape points' }))
|
|
109
|
+
expect(result.current.label).toBe('Shape points')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('updates the label when a later event declares a different one', () => {
|
|
113
|
+
const eb = makeEventBus()
|
|
114
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
115
|
+
act(() => eb.emit(SET_FEATURES, { items: [], label: 'Map features' }))
|
|
116
|
+
act(() => eb.emit(SET_FEATURES, { items: [], label: 'Shape points' }))
|
|
117
|
+
expect(result.current.label).toBe('Shape points')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ─── useSpatialListItems — focusable updates ─────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe('useSpatialListItems — focusable updates', () => {
|
|
124
|
+
it('defaults to focusable: true before any event', () => {
|
|
125
|
+
const { result } = renderHook(() => useSpatialListItems(makeEventBus()))
|
|
126
|
+
expect(result.current.focusable).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('defaults to focusable: true when the event omits it', () => {
|
|
130
|
+
const eb = makeEventBus()
|
|
131
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
132
|
+
act(() => eb.emit(SET_FEATURES, { items: [] }))
|
|
133
|
+
expect(result.current.focusable).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('reflects an explicit focusable: false from the current provider', () => {
|
|
137
|
+
const eb = makeEventBus()
|
|
138
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
139
|
+
act(() => eb.emit(SET_FEATURES, { items: [], focusable: false }))
|
|
140
|
+
expect(result.current.focusable).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('reverts to true once a later event omits it again (e.g. draw released its claim)', () => {
|
|
144
|
+
const eb = makeEventBus()
|
|
145
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
146
|
+
act(() => eb.emit(SET_FEATURES, { items: [], focusable: false }))
|
|
147
|
+
act(() => eb.emit(SET_FEATURES, { items: [] }))
|
|
148
|
+
expect(result.current.focusable).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// ─── useSpatialListItems — multiselectable updates ───────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe('useSpatialListItems — multiselectable updates', () => {
|
|
155
|
+
it('sets multiselectable true when emitted with multiselectable: true', () => {
|
|
156
|
+
const eb = makeEventBus()
|
|
157
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
158
|
+
act(() => eb.emit(SET_FEATURES, { items: [], multiselectable: true }))
|
|
159
|
+
expect(result.current.multiselectable).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('defaults multiselectable to false when not present in payload', () => {
|
|
163
|
+
const eb = makeEventBus()
|
|
164
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
165
|
+
act(() => eb.emit(SET_FEATURES, { items: [] }))
|
|
166
|
+
expect(result.current.multiselectable).toBe(false)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ─── useSpatialListItems — suppression ───────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
describe('useSpatialListItems — suppression', () => {
|
|
173
|
+
const items = [{ id: 'a', label: 'Feature A' }]
|
|
174
|
+
|
|
175
|
+
it('reports empty items while suppressed, regardless of what was last set', () => {
|
|
176
|
+
const eb = makeEventBus()
|
|
177
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
178
|
+
act(() => eb.emit(SET_FEATURES, { items }))
|
|
179
|
+
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
|
|
180
|
+
expect(result.current.items).toEqual([])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('restores the last known items once suppression is lifted', () => {
|
|
184
|
+
const eb = makeEventBus()
|
|
185
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
186
|
+
act(() => eb.emit(SET_FEATURES, { items }))
|
|
187
|
+
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
|
|
188
|
+
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
|
|
189
|
+
expect(result.current.items).toEqual(items)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('defaults suppressed to false when the payload omits it', () => {
|
|
193
|
+
const eb = makeEventBus()
|
|
194
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
195
|
+
act(() => eb.emit(SET_FEATURES, { items }))
|
|
196
|
+
act(() => eb.emit(SET_FEATURES_SUPPRESSED, {}))
|
|
197
|
+
expect(result.current.items).toEqual(items)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('defaults suppressed to false when the event carries no payload at all', () => {
|
|
201
|
+
const eb = makeEventBus()
|
|
202
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
203
|
+
act(() => eb.emit(SET_FEATURES, { items }))
|
|
204
|
+
act(() => eb.emit(SET_FEATURES_SUPPRESSED))
|
|
205
|
+
expect(result.current.items).toEqual(items)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('a features update received while suppressed is still applied once restored', () => {
|
|
209
|
+
const eb = makeEventBus()
|
|
210
|
+
const { result } = renderHook(() => useSpatialListItems(eb))
|
|
211
|
+
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: true }))
|
|
212
|
+
act(() => eb.emit(SET_FEATURES, { items }))
|
|
213
|
+
expect(result.current.items).toEqual([]) // still suppressed
|
|
214
|
+
act(() => eb.emit(SET_FEATURES_SUPPRESSED, { suppressed: false }))
|
|
215
|
+
expect(result.current.items).toEqual(items)
|
|
216
|
+
})
|
|
217
|
+
})
|
package/src/App/initialiseApp.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createPanelRegistry } from './registry/panelRegistry.js'
|
|
|
6
6
|
import { createControlRegistry } from './registry/controlRegistry.js'
|
|
7
7
|
import { createPluginRegistry } from './registry/pluginRegistry.js'
|
|
8
8
|
import { createKeyboardShortcutRegistry } from './registry/keyboardShortcutRegistry.js'
|
|
9
|
+
import { createSpatialListRegistry } from './registry/spatialListRegistry.js'
|
|
9
10
|
import { mergeManifests } from './registry/mergeManifests.js'
|
|
10
11
|
import { App } from './App.jsx'
|
|
11
12
|
|
|
@@ -13,13 +14,14 @@ const rootMap = new WeakMap()
|
|
|
13
14
|
const mapProviderMap = new WeakMap()
|
|
14
15
|
const registryMap = new WeakMap()
|
|
15
16
|
|
|
16
|
-
const getOrCreateRegistries = (rootElement) => {
|
|
17
|
+
const getOrCreateRegistries = (rootElement, eventBus) => {
|
|
17
18
|
let registries = registryMap.get(rootElement)
|
|
18
19
|
if (!registries) {
|
|
19
20
|
const buttonRegistry = createButtonRegistry()
|
|
20
21
|
const panelRegistry = createPanelRegistry()
|
|
21
22
|
const controlRegistry = createControlRegistry()
|
|
22
23
|
const keyboardShortcutRegistry = createKeyboardShortcutRegistry()
|
|
24
|
+
const spatialListRegistry = createSpatialListRegistry({ eventBus })
|
|
23
25
|
const pluginRegistry = createPluginRegistry({
|
|
24
26
|
registerButton: buttonRegistry.registerButton,
|
|
25
27
|
registerPanel: panelRegistry.registerPanel,
|
|
@@ -27,7 +29,7 @@ const getOrCreateRegistries = (rootElement) => {
|
|
|
27
29
|
registerKeyboardShortcut: keyboardShortcutRegistry.registerKeyboardShortcut
|
|
28
30
|
})
|
|
29
31
|
|
|
30
|
-
registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry }
|
|
32
|
+
registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry, spatialListRegistry }
|
|
31
33
|
registryMap.set(rootElement, registries)
|
|
32
34
|
}
|
|
33
35
|
return registries
|
|
@@ -73,7 +75,7 @@ export async function initialiseApp (rootElement, {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
// Reuse or create registries (persist across app open/close cycles)
|
|
76
|
-
const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry } = getOrCreateRegistries(rootElement)
|
|
78
|
+
const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry, spatialListRegistry } = getOrCreateRegistries(rootElement, eventBus)
|
|
77
79
|
const { registerPlugin } = pluginRegistry
|
|
78
80
|
|
|
79
81
|
// Register provider-supported shortcuts
|
|
@@ -124,6 +126,7 @@ export async function initialiseApp (rootElement, {
|
|
|
124
126
|
controlRegistry={controlRegistry}
|
|
125
127
|
pluginRegistry={pluginRegistry}
|
|
126
128
|
keyboardShortcutRegistry={keyboardShortcutRegistry}
|
|
129
|
+
spatialListRegistry={spatialListRegistry}
|
|
127
130
|
mapProvider={mapProvider}
|
|
128
131
|
/>)
|
|
129
132
|
|
|
@@ -50,6 +50,10 @@ jest.mock('./registry/keyboardShortcutRegistry.js', () => ({
|
|
|
50
50
|
createKeyboardShortcutRegistry: jest.fn(() => mockRegistries.keyboardShortcutRegistry)
|
|
51
51
|
}))
|
|
52
52
|
|
|
53
|
+
jest.mock('./registry/spatialListRegistry.js', () => ({
|
|
54
|
+
createSpatialListRegistry: jest.fn(() => mockRegistries.spatialListRegistry)
|
|
55
|
+
}))
|
|
56
|
+
|
|
53
57
|
jest.mock('./registry/mergeManifests.js', () => ({
|
|
54
58
|
mergeManifests: jest.fn((base, override) => ({ ...base, ...override }))
|
|
55
59
|
}))
|
|
@@ -193,4 +197,11 @@ describe('initialiseApp', () => {
|
|
|
193
197
|
const rendered = createRoot.mock.results[0].value.render.mock.calls[0][0]
|
|
194
198
|
expect(rendered.props.keyboardShortcutRegistry).toBe(mockRegistries.keyboardShortcutRegistry)
|
|
195
199
|
})
|
|
200
|
+
|
|
201
|
+
test('passes spatialListRegistry through to the rendered App', async () => {
|
|
202
|
+
await initApp()
|
|
203
|
+
|
|
204
|
+
const rendered = createRoot.mock.results[0].value.render.mock.calls[0][0]
|
|
205
|
+
expect(rendered.props.spatialListRegistry).toBe(mockRegistries.spatialListRegistry)
|
|
206
|
+
})
|
|
196
207
|
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { EVENTS as events } from '../../config/events.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Central registry for plugins that contribute items to the shared spatial list (see
|
|
5
|
+
* SpatialList.jsx). Plugins register a live item provider — `{ getItems, exclusive? }` —
|
|
6
|
+
* instead of emitting the list event themselves, so exclusive takeover (draw mid-edit) and
|
|
7
|
+
* additive participation (interact's features/markers) both work safely with any number of
|
|
8
|
+
* contributors.
|
|
9
|
+
*
|
|
10
|
+
* `getItems()` returns `{ items, multiselectable, label, focusable }`, called fresh on every
|
|
11
|
+
* recompute rather than snapshotted at registration. `label` becomes the listbox's aria-label;
|
|
12
|
+
* `focusable: false` (draw's vertex/midpoint items) keeps every item out of the Tab order —
|
|
13
|
+
* see SpatialList.jsx. `exclusive: true` claims sole ownership while held (see
|
|
14
|
+
* claimExclusive/releaseExclusive); everything else is additive and gets concatenated
|
|
15
|
+
* together. Providers call notifyItemsChanged whenever their own items would differ — the
|
|
16
|
+
* registry never polls, it only recomputes in response to a caller flagging a change.
|
|
17
|
+
*/
|
|
18
|
+
export function createSpatialListRegistry ({ eventBus }) {
|
|
19
|
+
const providers = new Map() // pluginId -> { getItems, exclusive }
|
|
20
|
+
let exclusiveOwner = null
|
|
21
|
+
|
|
22
|
+
const recompute = () => {
|
|
23
|
+
let items = []
|
|
24
|
+
let multiselectable = false
|
|
25
|
+
let label
|
|
26
|
+
let focusable
|
|
27
|
+
|
|
28
|
+
if (exclusiveOwner && providers.has(exclusiveOwner)) {
|
|
29
|
+
const result = providers.get(exclusiveOwner).getItems() ?? {}
|
|
30
|
+
items = result.items ?? []
|
|
31
|
+
multiselectable = !!result.multiselectable
|
|
32
|
+
label = result.label
|
|
33
|
+
focusable = result.focusable
|
|
34
|
+
} else {
|
|
35
|
+
// Exclusive-capable providers only ever contribute while actually holding the
|
|
36
|
+
// claim (the branch above) — not just whenever nobody happens to hold it.
|
|
37
|
+
providers.forEach((provider) => {
|
|
38
|
+
if (provider.exclusive) { return }
|
|
39
|
+
const result = provider.getItems() ?? {}
|
|
40
|
+
items = items.concat(result.items ?? [])
|
|
41
|
+
multiselectable = multiselectable || !!result.multiselectable
|
|
42
|
+
// First additive provider (in registration order) with a label/focusable value
|
|
43
|
+
// wins — there's no principled way to merge two different values, and today
|
|
44
|
+
// only interact ever contributes additively, so this is just "whatever it
|
|
45
|
+
// declares".
|
|
46
|
+
label = label ?? result.label
|
|
47
|
+
focusable = focusable ?? result.focusable
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
eventBus.emit(events.MAP_SET_SPATIAL_LIST, { items, multiselectable, label, focusable })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function registerItemProvider (pluginId, { getItems, exclusive = false }) {
|
|
55
|
+
providers.set(pluginId, { getItems, exclusive })
|
|
56
|
+
recompute()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unregisterItemProvider (pluginId) {
|
|
60
|
+
providers.delete(pluginId)
|
|
61
|
+
if (exclusiveOwner === pluginId) {
|
|
62
|
+
exclusiveOwner = null
|
|
63
|
+
}
|
|
64
|
+
recompute()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Mirrors mapProvider.activeMoveTarget's single-claim contract (MapControls.jsx),
|
|
68
|
+
// generalised to however many participants are registered rather than always
|
|
69
|
+
// exactly two — only one exclusive claim can be held at a time, and holding it
|
|
70
|
+
// means every other registered provider is ignored until it's released.
|
|
71
|
+
function claimExclusive (pluginId) {
|
|
72
|
+
exclusiveOwner = pluginId
|
|
73
|
+
recompute()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function releaseExclusive (pluginId) {
|
|
77
|
+
if (exclusiveOwner === pluginId) {
|
|
78
|
+
exclusiveOwner = null
|
|
79
|
+
recompute()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function notifyItemsChanged (pluginId) {
|
|
84
|
+
if (!providers.has(pluginId)) { return }
|
|
85
|
+
// If someone else holds the exclusive claim, this provider isn't feeding the
|
|
86
|
+
// list right now regardless of what changed for it — nothing to recompute.
|
|
87
|
+
if (exclusiveOwner && exclusiveOwner !== pluginId) { return }
|
|
88
|
+
recompute()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function clear () {
|
|
92
|
+
providers.clear()
|
|
93
|
+
exclusiveOwner = null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
registerItemProvider,
|
|
98
|
+
unregisterItemProvider,
|
|
99
|
+
claimExclusive,
|
|
100
|
+
releaseExclusive,
|
|
101
|
+
notifyItemsChanged,
|
|
102
|
+
clear
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createSpatialListRegistry } from './spatialListRegistry.js'
|
|
2
|
+
|
|
3
|
+
const SET_FEATURES = 'map:setspatiallist'
|
|
4
|
+
|
|
5
|
+
const makeEventBus = () => ({ emit: jest.fn() })
|
|
6
|
+
|
|
7
|
+
const lastEmit = (eventBus) => eventBus.emit.mock.calls.at(-1)[1]
|
|
8
|
+
|
|
9
|
+
describe('createSpatialListRegistry', () => {
|
|
10
|
+
test('registering a provider recomputes and emits its items immediately', () => {
|
|
11
|
+
const eventBus = makeEventBus()
|
|
12
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
13
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }], multiselectable: false }) })
|
|
14
|
+
expect(eventBus.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [{ id: '1' }], multiselectable: false })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('concatenates items from every registered provider when no exclusive claim is held', () => {
|
|
18
|
+
const eventBus = makeEventBus()
|
|
19
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
20
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }) })
|
|
21
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [{ id: '2' }] }) })
|
|
22
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: '1' }, { id: '2' }], multiselectable: false })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('multiselectable is true if any additive provider wants it', () => {
|
|
26
|
+
const eventBus = makeEventBus()
|
|
27
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
28
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [], multiselectable: false }) })
|
|
29
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [], multiselectable: true }) })
|
|
30
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: true })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('getItems is called fresh on every recompute, not snapshotted at registration', () => {
|
|
34
|
+
const eventBus = makeEventBus()
|
|
35
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
36
|
+
let current = [{ id: 'first' }]
|
|
37
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: current }) })
|
|
38
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'first' }], multiselectable: false })
|
|
39
|
+
|
|
40
|
+
current = [{ id: 'second' }]
|
|
41
|
+
registry.notifyItemsChanged('a')
|
|
42
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'second' }], multiselectable: false })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('unregistering a provider removes its items from the next recompute', () => {
|
|
46
|
+
const eventBus = makeEventBus()
|
|
47
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
48
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }) })
|
|
49
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [{ id: '2' }] }) })
|
|
50
|
+
registry.unregisterItemProvider('a')
|
|
51
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: '2' }], multiselectable: false })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('notifyItemsChanged for an unregistered id is a no-op', () => {
|
|
55
|
+
const eventBus = makeEventBus()
|
|
56
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
57
|
+
registry.notifyItemsChanged('ghost')
|
|
58
|
+
expect(eventBus.emit).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('exclusive claims', () => {
|
|
62
|
+
test('claiming exclusive ownership excludes every other provider\'s items', () => {
|
|
63
|
+
const eventBus = makeEventBus()
|
|
64
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
65
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
|
|
66
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
|
|
67
|
+
|
|
68
|
+
registry.claimExclusive('draw')
|
|
69
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'vertex-1' }], multiselectable: false })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('releasing the exclusive claim reverts to the additive merge of everyone else', () => {
|
|
73
|
+
const eventBus = makeEventBus()
|
|
74
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
75
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
|
|
76
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
|
|
77
|
+
registry.claimExclusive('draw')
|
|
78
|
+
|
|
79
|
+
registry.releaseExclusive('draw')
|
|
80
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'feature-1' }], multiselectable: false })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('releaseExclusive from a provider that does not currently hold the claim is a no-op', () => {
|
|
84
|
+
const eventBus = makeEventBus()
|
|
85
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
86
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
|
|
87
|
+
eventBus.emit.mockClear()
|
|
88
|
+
|
|
89
|
+
registry.releaseExclusive('draw') // never claimed it
|
|
90
|
+
expect(eventBus.emit).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('a non-exclusive-owning provider\'s own changes are ignored while someone else holds the claim', () => {
|
|
94
|
+
const eventBus = makeEventBus()
|
|
95
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
96
|
+
let interactItems = [{ id: 'feature-1' }]
|
|
97
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: interactItems }) })
|
|
98
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
|
|
99
|
+
registry.claimExclusive('draw')
|
|
100
|
+
eventBus.emit.mockClear()
|
|
101
|
+
|
|
102
|
+
interactItems = [{ id: 'feature-2' }] // changed while draw holds exclusive ownership
|
|
103
|
+
registry.notifyItemsChanged('interact')
|
|
104
|
+
expect(eventBus.emit).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('unregistering the current exclusive owner clears the claim and reverts to additive', () => {
|
|
108
|
+
const eventBus = makeEventBus()
|
|
109
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
110
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
|
|
111
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
|
|
112
|
+
registry.claimExclusive('draw')
|
|
113
|
+
|
|
114
|
+
registry.unregisterItemProvider('draw')
|
|
115
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'feature-1' }], multiselectable: false })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('label', () => {
|
|
120
|
+
test('passes through the exclusive claimant\'s own label while its claim is held', () => {
|
|
121
|
+
const eventBus = makeEventBus()
|
|
122
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
123
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [], label: 'Map features' }) })
|
|
124
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [], label: 'Shape points' }), exclusive: true })
|
|
125
|
+
|
|
126
|
+
registry.claimExclusive('draw')
|
|
127
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'Shape points' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('uses the first additive provider\'s label (in registration order) when more than one contributes', () => {
|
|
131
|
+
const eventBus = makeEventBus()
|
|
132
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
133
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [], label: 'First' }) })
|
|
134
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [], label: 'Second' }) })
|
|
135
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'First' })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('falls back to a later provider\'s label when an earlier one declares none', () => {
|
|
139
|
+
const eventBus = makeEventBus()
|
|
140
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
141
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [] }) }) // no label
|
|
142
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [], label: 'Second' }) })
|
|
143
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'Second' })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('label is undefined when nothing is registered', () => {
|
|
147
|
+
const eventBus = makeEventBus()
|
|
148
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
149
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [] }) })
|
|
150
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('focusable', () => {
|
|
155
|
+
test('passes through the exclusive claimant\'s own focusable: false while its claim is held', () => {
|
|
156
|
+
const eventBus = makeEventBus()
|
|
157
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
158
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [] }) }) // focusable defaults true
|
|
159
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [], focusable: false }), exclusive: true })
|
|
160
|
+
|
|
161
|
+
registry.claimExclusive('draw')
|
|
162
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, focusable: false })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('reverts to undefined (consumer default: focusable) once the claim is released', () => {
|
|
166
|
+
const eventBus = makeEventBus()
|
|
167
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
168
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [] }) })
|
|
169
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [], focusable: false }), exclusive: true })
|
|
170
|
+
registry.claimExclusive('draw')
|
|
171
|
+
|
|
172
|
+
registry.releaseExclusive('draw')
|
|
173
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('uses the first additive provider\'s explicit focusable value when more than one contributes', () => {
|
|
177
|
+
const eventBus = makeEventBus()
|
|
178
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
179
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [] }) }) // no explicit value
|
|
180
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [], focusable: false }) })
|
|
181
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, focusable: false })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('focusable is undefined when nothing declares it', () => {
|
|
185
|
+
const eventBus = makeEventBus()
|
|
186
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
187
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [] }) })
|
|
188
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('clear removes every provider and any exclusive claim', () => {
|
|
193
|
+
const eventBus = makeEventBus()
|
|
194
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
195
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }), exclusive: true })
|
|
196
|
+
registry.claimExclusive('a')
|
|
197
|
+
|
|
198
|
+
registry.clear()
|
|
199
|
+
eventBus.emit.mockClear()
|
|
200
|
+
registry.notifyItemsChanged('a') // 'a' is no longer registered
|
|
201
|
+
expect(eventBus.emit).not.toHaveBeenCalled()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('a provider whose getItems returns nothing contributes no items and does not throw', () => {
|
|
205
|
+
const eventBus = makeEventBus()
|
|
206
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
207
|
+
expect(() => registry.registerItemProvider('a', { getItems: () => undefined })).not.toThrow()
|
|
208
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('an exclusive provider whose getItems returns nothing contributes no items while holding the claim', () => {
|
|
212
|
+
const eventBus = makeEventBus()
|
|
213
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
214
|
+
registry.registerItemProvider('draw', { getItems: () => undefined, exclusive: true })
|
|
215
|
+
expect(() => registry.claimExclusive('draw')).not.toThrow()
|
|
216
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
217
|
+
})
|
|
218
|
+
})
|